The compatibility layer serves Resend's paths at the root of https://api.minket.com, hyphens included where Resend has them (/contact-properties, /api-keys), and takes the same keys as the native API. Bodies follow the resend SDK's type declarations (6.x) field for field. Contract tests run the official SDK at 6.10.0, 6.12.4, 6.17.1, 6.22.1 and the latest release against the API over HTTP and check the { data, error } it hands back, error names and statuses included; a call marked with a version below needs that SDK version or later. Move a project from Resend has the steps.

Paths

Paths SDK calls Scope
POST /emails, POST /emails/batch, PATCH /emails/:id, POST /emails/:id/cancel emails.send, batch.send, emails.update, emails.cancel sending_access
POST /emails/:id/share emails.share (6.22.1), for a sent or a received email full_access
GET /emails, GET /emails/:id, GET /emails/:id/attachments, GET /emails/:id/attachments/:attachmentId, GET /emails/metrics emails.list, emails.get, emails.attachments.list, emails.attachments.get, emails.metrics (6.22.1) full_access
GET /emails/receiving, GET /emails/receiving/:id, GET /emails/receiving/:id/attachments, GET /emails/receiving/:id/attachments/:attachmentId emails.receiving.list, emails.receiving.get, emails.receiving.attachments.* inbound_read
POST /emails/receiving/:id/forward forwarding a received email full_access
POST /domains, GET /domains, GET /domains/:id, PATCH /domains/:id, DELETE /domains/:id, POST /domains/:id/verify domains.* full_access
POST /webhooks, GET /webhooks, GET /webhooks/:id, PATCH /webhooks/:id, DELETE /webhooks/:id, POST /webhooks/:id/signing-secret/rotate, GET /webhooks/:id/events, GET /webhooks/:id/events/:eventId, POST /webhooks/:id/events/:eventId/replay, GET /webhooks/:id/events/:eventId/attempts webhooks.*, webhooks.events.* full_access
/contacts, /contacts/:id, /contacts/imports, /contacts/:id/topics, /contacts/:id/segments, /contacts/:id/segments/:segmentId, /contact-properties, /segments, /audiences (legacy), /audiences/:id/contacts, /topics contacts, properties, segments, audiences, topics full_access
/templates, /templates/:id, /templates/:id/publish, /templates/:id/duplicate templates.* full_access
/broadcasts, /broadcasts/:id, /broadcasts/:id/send, /broadcasts/:id/cancel, /broadcasts/:id/duplicate, /broadcasts/:id/recipients, /broadcasts/:id/clicked-links broadcasts.* full_access
/events, /events/send, /events/:id, /automations, /automations/:id, /automations/:id/stop, /automations/:id/duplicate, /automations/:id/runs, /automations/:id/runs/:runId events and automations full_access
/api-keys, /api-keys/:id, /logs, /logs/:id apiKeys.*, logs full_access

Calls that are not mirrored answer what Resend answers for an endpoint it does not serve, 404 not_found with "The requested endpoint does not exist.", whatever the method: domains.claims.*, suppressions.* (the native suppressions cover them), oauthGrants.*, inboxes.* and usage.get. A wrong method on a mirrored path answers 405 method_not_allowed with an allow header, and a path id that is not a UUID answers 404 not_found.

Emails

  • POST /emails takes from, to, cc, bcc, reply_to, subject, html, text, headers, attachments, tags, scheduled_at, topic_id and template, and returns { "id": "…" }. The native extras (stream, via, tracking, idempotency_key in the body, attachments by id) are not taken. Attachments carry content (a base64 string, a serialized Buffer or a byte array), filename, path, content_type and content_id. An Idempotency-Key header is honoured for 24 hours. region is accepted and ignored.
  • POST /emails/batch takes up to 100 emails, scheduled_at and attachments included (Resend's batch takes no attachments). With the default x-batch-validation: strict, one invalid item rejects the batch and nothing is stored; with permissive the valid items are sent and the rest are listed in errors as { "index": 0, "message": "…" }. The answer is { "data": [{ "id": "…" }], "errors": [...] }.
  • GET /emails/:id returns object, id, to, from, created_at, subject, bcc, cc, reply_to, html, text, last_event, message_id, tags, topic_id and scheduled_at. last_event is the email's status; message_id is an empty string until SES reports the send.
  • PATCH /emails/:id takes { "scheduled_at": "…" }; it and POST /emails/:id/cancel return { "object": "email", "id": "…" }.
  • emails.share and emails.metrics answer with the results of sharing and metrics.
  • /logs lists the requests made with the project's keys in Resend's shape, with request_body and response_body always null: bodies are never stored.
  • A sending_access key sends, reschedules and cancels; reading mail needs a full-access key (401 restricted_api_key).

Errors

Errors are Resend's flat body, with Resend's phrasing for validation messages:

JSON
{ "statusCode": 422, "message": "Missing `to` field.", "name": "validation_error" }

Each code reports this name; codes Resend does not have take the closest of Resend's:

CodeStatusName on the compatibility layer
invalid_idempotency_key400invalid_idempotency_key
invalid_sign_in_code400validation_error
missing_api_key401missing_api_key
restricted_api_key401restricted_api_key
unauthorized401missing_api_key
account_blocked403invalid_access
domain_authentication_failed403invalid_from_address
domain_not_verified403invalid_from_address
forbidden403invalid_access
invalid_access403invalid_access
invalid_api_key403invalid_api_key
invalid_from_address403invalid_from_address
organization_suspended403invalid_access
signup_closed403invalid_access
test_mode_only403restricted_api_key
not_found404not_found
method_not_allowed405method_not_allowed
concurrent_idempotent_requests409concurrent_idempotent_requests
conflict409validation_error
invalid_state409validation_error
mailbox_unavailable409application_error
payload_too_large413validation_error
unsupported_media_type415validation_error
content_blocked422validation_error
invalid_attachment422invalid_attachment
invalid_idempotent_request422invalid_idempotent_request
invalid_parameter422invalid_parameter
invalid_region422invalid_region
missing_required_field422missing_required_field
stream_rule_violation422validation_error
validation_error422validation_error
daily_quota_exceeded429daily_quota_exceeded
monthly_quota_exceeded429monthly_quota_exceeded
rate_limit_exceeded429rate_limit_exceeded
security_error451security_error
application_error500application_error
internal_server_error500internal_server_error
not_implemented501application_error
outcome_unknown502application_error
upstream_unavailable502application_error
sending_paused503application_error

API keys

apiKeys.create, list, update (6.22.1) and remove work against /api-keys. The new key takes the calling key's project, environment and mode; permission is full_access (the default) or sending_access, and domain_id restricts a sending_access key to a domain. The calling key must be a live full-access key. update changes only the name and answers { object: "api_key", id }; remove answers { object: "api_key", id, deleted: true }.

Domains

  • POST /domains takes name, region (us-east-1 only; other regions answer invalid_region), custom_return_path (the MAIL FROM label), capabilities (sending, receiving), open_tracking, click_tracking, tls and tracking_subdomain, and returns the domain with its records.
  • records are SPF (the MAIL FROM MX and TXT), DKIM, Receiving, Tracking and TrackingCAA entries, each with record, name relative to the registrable domain (bounce.mail under acme.example), value, type, ttl (Auto), status and, for MX, priority. DMARC is not one of Resend's record kinds; read it from GET /v1/domains/{id} or the console.
  • verify checks now and answers { object, id }; remove answers { object, id, deleted: true }.
  • Domains report the regions they are verified in: us-east-1, plus us-west-2 with failover.
  • The DKIM selectors are SES's, not Resend's, and the custom MAIL FROM is bounce.<domain> where Resend uses send.<domain>, so both sets of records coexist and a domain stays valid in Resend during a migration.

Templates

templates.create, list, get, update, remove, publish and duplicate work, including the chained .publish(). Resend's variables {{{NAME}}} and {{{NAME|fallback}}} work in any case and are escaped like {{name}} (Variables); a variable's fallback is fallbackValue.

TypeScript
await resend.templates
  .create({
    name: "Order confirmation",
    alias: "order-confirmation",
    subject: "Order {{{ORDER_ID}}}",
    html: "<p>Thanks for buying {{{PRODUCT}}}.</p>",
    variables: [
      { key: "PRODUCT", type: "string", fallbackValue: "your order" },
      { key: "ORDER_ID", type: "string" },
    ],
  })
  .publish();

await resend.emails.send({
  from: "Acme <shop@mail.acme.example>",
  to: "jane@example.com",
  template: { id: "order-confirmation", variables: { PRODUCT: "Desk lamp", ORDER_ID: "10452" } },
});

Webhooks

  • POST /webhooks takes { "endpoint", "events" } and returns { "object": "webhook", "id", "signing_secret" }. events is required and limited to the types marked below. The endpoint is created in the key's environment and mode, with a new signing secret.
  • GET /webhooks and GET /webhooks/:id return endpoint, events, created_at and status (enabled, or disabled for an endpoint disabled by hand or automatically); GET /webhooks/:id includes signing_secret.
  • PATCH /webhooks/:id takes endpoint, events and status; DELETE /webhooks/:id answers { "object": "webhook", "id", "deleted": true }.
  • rotateSigningSecret (6.34): POST /webhooks/:id/signing-secret/rotate answers { "object": "webhook", "id", "signing_secret" }, and the previous secret keeps signing for 24 hours.
  • events.* (6.34): GET /webhooks/:id/events lists the events sent to the endpoint, newest first, as { "id", "type", "created_at", "status" }, paged with limit and after. The id is the svix-id the endpoint received (msg_…). status is pending before the first attempt, attempting while one runs or a retry waits, success once an attempt got a 2xx, and failed when none did and no more will run. GET /webhooks/:id/events/:eventId adds next_attempt_at and payload, the body as sent; .../attempts lists its attempts as { "id", "http_status_code", "response", "sent_at" }, with http_status_code 0 when no response came back. POST /webhooks/:id/events/:eventId/replay sends the event once more now, with the same body and svix-id, and answers { "object": "webhook_event", "id" }.

These read the same deliveries as the native routes, grouped by event where those list each delivery. Endpoints made on the compatibility layer subscribe to Resend's event types only:

EventOn compatibility endpoints
email.sentyes
email.scheduledyes
email.deliveredyes
email.delivery_delayedyes
email.complainedyes
email.bouncedyes
email.openedyes
email.clickedyes
email.receivedyes
email.failedyes
email.suppressedyes
email.canceledno
contact.createdyes
contact.updatedyes
contact.deletedyes
domain.createdyes
domain.updatedyes
domain.deletedyes
broadcast.startedno
broadcast.completedno
automation.completedno
automation.failedno

Deliveries are signed and shaped as Resend's (Verify signatures), so resend.webhooks.verify keeps working with the new secret:

TypeScript
const event = resend.webhooks.verify({
  payload: await request.text(),
  headers: {
    id: request.headers.get("svix-id") ?? "",
    timestamp: request.headers.get("svix-timestamp") ?? "",
    signature: request.headers.get("svix-signature") ?? "",
  },
  webhookSecret: process.env.MINKET_WEBHOOK_SECRET ?? "",
});

Received mail

  • GET /emails/receiving and GET /emails/receiving/:id (with ?html_format=data_uri|cid) return Resend's receiving email: object, id, to, from, created_at, subject, bcc, cc, reply_to, received_for (the envelope recipients), html, text, headers, message_id, raw and attachments. The single email also has authentication: spf, dkim and dmarc, each pass, fail, gray, processing_failed or unknown, from SES's verdicts. gray is SPF none, softfail or neutral, DKIM unsigned or signed only by a domain outside the From domain's organizational domain, and DMARC none. Resend also says gray for DMARC under p=none, which SES's header does not show, so such mail reads pass here.
  • GET /emails/receiving/:id/attachments and /attachments/:attachmentId return download_url and expires_at.
  • The email.received webhook carries the parsed content under data.content (text, html, truncated) and the verdicts. There is no data.text, data.html or data.headers, so a handler that reads those reads data.content instead, or calls emails.receiving.get.
  • resend.emails.receiving.forward() runs in the SDK: it downloads raw.download_url and sends a new email with POST /emails, so it needs a key that can send and a from on a verified domain, and it is an ordinary send, not a platform forward (no confirmation, no ARC). POST /emails/receiving/:id/forward is the platform forward with Resend's body (to, from, passthrough, text, html).

Contacts, segments and topics

Calls Paths
contacts.create, list, get, update, remove (by id or email, with or without audienceId) /contacts, /contacts/:id, /audiences/:id/contacts, /segments/:id/contacts
contacts.topics.list, update; contacts.segments.list, add, remove /contacts/:id/topics, /contacts/:id/segments
contacts.imports.create, list, get (the SDK uploads the file as form data) /contacts/imports
contactProperties.* (string and number) /contact-properties
segments.* and its alias audiences.* /segments, /audiences
topics.* /topics

Segments made here are static lists. An unsubscribe on the hosted pages shows up in contacts.get as unsubscribed: true.

Broadcasts

broadcasts.create, list, get, update, remove and send work, and so do cancel, recipients and clickedLinks (6.22) and duplicate (6.34), in Resend's shapes.

  • audience_id is accepted as the older name of segment_id. create with send: true sends at once and, if the send is refused, leaves no draft behind.
  • Resend has three statuses: draft, queued (scheduled, sending or paused here) and sent.
  • duplicate answers { "object": "broadcast", "id" } with a draft.
  • recipients(id, { type, email, bounceType }) is the recipient list filtered by event: each item has id (a cursor, the contact id), contact_id and email, plus count, clicked_links or bounce_type as the type adds them. clickedLinks(id) is GET /v1/broadcasts/{id}/links. Opens and clicks are counted once per day (clicks per link), where Resend counts every repeat.
  • Resend's variables work in a broadcast's content: {{{FIRST_NAME}}}, {{{EMAIL}}}, property keys in upper case and {{{RESEND_UNSUBSCRIBE_URL}}}.

Automations and events

Resend's automations and events work from SDK 6.12.4:

TypeScript
await resend.events.create({ name: "user.signed_up", schema: { plan: "string" } });
const { data } = await resend.automations.create({
  name: "Welcome",
  status: "enabled",
  steps: [
    { key: "start", type: "trigger", config: { eventName: "user.signed_up" } },
    { key: "welcome", type: "send_email", config: { template: { id: "welcome", variables: { name: { var: "contact.first_name" } } } } },
    { key: "wait", type: "delay", config: { duration: "1 day" } },
    { key: "pro", type: "condition", config: { type: "rule", field: "plan", operator: "eq", value: "pro" } },
    { key: "trial", type: "contact_update", config: { properties: { plan: "trial" } } },
  ],
  connections: [
    { from: "start", to: "welcome" },
    { from: "welcome", to: "wait" },
    { from: "wait", to: "pro" },
    { from: "pro", to: "trial", type: "condition_not_met" },
  ],
});
await resend.events.send({ event: "user.signed_up", email: "jane@example.com", payload: { plan: "free" } });
Resend Minket
trigger step, event_name the trigger: contact.created, contact.updated, an email event type, else a custom event; topic.subscribed with topic_id and segment.entered with segment_id are Minket's
delay, duration (30 minutes, 2 hours, 3 days, 1 week, PT2H) wait
send_email, template, subject, from, reply_to send_email (variables { "var": "x" } are {{x}})
wait_for_event, event_name, timeout (90 days when omitted) wait_for_event; connections event_received and timeout
condition, a rule or an and/or group of rules branch with condition; connections condition_met and condition_not_met. Fields are contact fields (email, first_name, ...) or property keys; exists and is_empty are is_set and is_not_set
contact_update, contact_delete, add_to_segment update_contact, delete_contact, segment with add
status: "enabled" / "disabled" published and active / any other status
  • Steps Resend does not have read back in the same shape with Minket's names: remove_from_segment ({ segment_id }), topic ({ action, topic_id }), exit and the condition config { "type": "engagement", "event", "step" }.
  • An automation made here lets a contact run again once their run ended (allow_reentry), one run at a time; an update keeps the entry rule and re-entry setting it had.
  • remove archives the automation; afterwards it is not found. stop answers { "object": "automation", "id", "status": "disabled" }. duplicate (6.22.1) answers { "object": "automation", "id" } with a draft that reads disabled.
  • Runs read running (also while waiting), completed (also exited), failed or cancelled (stopped); their steps start with the trigger.
  • /events keeps event definitions: events.get takes the id or the name, and a schema's field types (string, number, boolean, date) are checked when an event with that name is sent, on both events.send and POST /v1/contacts/events. A name without a definition is accepted as it is. Test keys read definitions but cannot create, change or delete them; they can still events.send.
  • Not supported: filter_rule on wait_for_event and condition groups inside groups, both refused.

Other differences

  • Test mode. minket_test_ keys accept and store mail, play back events and never deliver (Test mode). Resend has no equivalent, so a project with its own sandbox transport can drop it.
  • Content checks. On a marketing domain a send is refused with 422 validation_error when the HTML is over 100 KB, the mail is images only, it links through a URL shortener, or it has no unsubscribe link; the message names the failed checks. Resend accepts these. A strict batch.send with one such item fails whole.
  • Rate limits. 10 requests per second per key by default, reported in ratelimit-limit, ratelimit-remaining and ratelimit-reset.

Updated