Resend compatibility reference
What the compatibility layer mirrors of Resend's API, path by path, and where its shapes, errors, events and webhooks differ from Resend's.
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 /, GET /, GET /emails/metrics |
emails.list, emails.get, emails.attachments.list, emails.attachments.get, emails.metrics (6.22.1) |
full_access |
GET /emails/receiving, GET /, GET /, GET / |
emails.receiving.list, emails.receiving.get, emails. |
inbound_read |
POST / |
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 /, GET /webhooks/:id/events, GET /, POST /, GET / |
webhooks.*, webhooks.events.* |
full_access |
/contacts, /contacts/:id, /contacts/imports, /contacts/:id/topics, /contacts/:id/segments, /, /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.* |
full_access |
/events, /events/send, /events/:id, /automations, /automations/:id, /automations/:id/stop, /, /automations/:id/runs, / |
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 /emailstakesfrom,to,cc,bcc,reply_to,subject,html,text,headers,attachments,tags,scheduled_at,topic_idandtemplate, and returns{ "id": "…" }. The native extras (stream,via,tracking,idempotency_keyin the body, attachments by id) are not taken. Attachments carrycontent(a base64 string, a serialized Buffer or a byte array),filename,path,content_typeandcontent_id. AnIdempotency-Keyheader is honoured for 24 hours.regionis accepted and ignored.POST /emails/batchtakes up to 100 emails,scheduled_atand attachments included (Resend's batch takes no attachments). With the defaultx-batch-validation: strict, one invalid item rejects the batch and nothing is stored; withpermissivethe valid items are sent and the rest are listed inerrorsas{ "index": 0, "message": "…" }. The answer is{ "data": [{ "id": "…" }], "errors": [...] }.GET /emails/:idreturnsobject,id,to,from,created_at,subject,bcc,cc,reply_to,html,text,last_event,message_id,tags,topic_idandscheduled_at.last_eventis the email's status;message_idis an empty string until SES reports the send.PATCH /emails/:idtakes{ "scheduled_at": "…" }; it andPOST /emails/:id/cancelreturn{ "object": "email", "id": "…" }.emails.shareandemails.metricsanswer with the results of sharing and metrics./logslists the requests made with the project's keys in Resend's shape, withrequest_bodyandresponse_bodyalways null: bodies are never stored.- A
sending_accesskey 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:
{ "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:
| Code | Status | Name on the compatibility layer |
|---|---|---|
invalid_idempotency_key | 400 | invalid_idempotency_key |
invalid_sign_in_code | 400 | validation_error |
missing_api_key | 401 | missing_api_key |
restricted_api_key | 401 | restricted_api_key |
unauthorized | 401 | missing_api_key |
account_blocked | 403 | invalid_access |
domain_authentication_failed | 403 | invalid_from_address |
domain_not_verified | 403 | invalid_from_address |
forbidden | 403 | invalid_access |
invalid_access | 403 | invalid_access |
invalid_api_key | 403 | invalid_api_key |
invalid_from_address | 403 | invalid_from_address |
organization_suspended | 403 | invalid_access |
signup_closed | 403 | invalid_access |
test_mode_only | 403 | restricted_api_key |
not_found | 404 | not_found |
method_not_allowed | 405 | method_not_allowed |
concurrent_idempotent_requests | 409 | concurrent_idempotent_requests |
conflict | 409 | validation_error |
invalid_state | 409 | validation_error |
mailbox_unavailable | 409 | application_error |
payload_too_large | 413 | validation_error |
unsupported_media_type | 415 | validation_error |
content_blocked | 422 | validation_error |
invalid_attachment | 422 | invalid_attachment |
invalid_idempotent_request | 422 | invalid_idempotent_request |
invalid_parameter | 422 | invalid_parameter |
invalid_region | 422 | invalid_region |
missing_required_field | 422 | missing_required_field |
stream_rule_violation | 422 | validation_error |
validation_error | 422 | validation_error |
daily_quota_exceeded | 429 | daily_quota_exceeded |
monthly_quota_exceeded | 429 | monthly_quota_exceeded |
rate_limit_exceeded | 429 | rate_limit_exceeded |
security_error | 451 | security_error |
application_error | 500 | application_error |
internal_server_error | 500 | internal_server_error |
not_implemented | 501 | application_error |
outcome_unknown | 502 | application_error |
upstream_unavailable | 502 | application_error |
sending_paused | 503 | application_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 /domainstakesname,region(us-east-1only; other regions answerinvalid_region),custom_return_path(the MAIL FROM label),capabilities(sending,receiving),open_tracking,click_tracking,tlsandtracking_subdomain, and returns the domain with itsrecords.recordsareSPF(the MAIL FROM MX and TXT),DKIM,Receiving,TrackingandTrackingCAAentries, each withrecord,namerelative to the registrable domain (bounce.mailunderacme.example),value,type,ttl(Auto),statusand, for MX,priority. DMARC is not one of Resend's record kinds; read it fromGET /v1/domains/{id}or the console.verifychecks now and answers{ object, id };removeanswers{ object, id, deleted: true }.- Domains report the regions they are verified in:
us-east-1, plusus-west-2with failover. - The DKIM selectors are SES's, not Resend's, and the custom MAIL FROM is
bounce.<domain>where Resend usessend.<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.
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 /webhookstakes{ "endpoint", "events" }and returns{ "object": "webhook", "id", "signing_secret" }.eventsis 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 /webhooksandGET /webhooks/:idreturnendpoint,events,created_atandstatus(enabled, ordisabledfor an endpoint disabled by hand or automatically);GET /webhooks/:idincludessigning_secret.PATCH /webhooks/:idtakesendpoint,eventsandstatus;DELETE /webhooks/:idanswers{ "object": "webhook", "id", "deleted": true }.rotateSigningSecret(6.34):POST /webhooks/:id/signing-secret/rotateanswers{ "object": "webhook", "id", "signing_secret" }, and the previous secret keeps signing for 24 hours.events.*(6.34):GET /webhooks/:id/eventslists the events sent to the endpoint, newest first, as{ "id", "type", "created_at", "status" }, paged withlimitandafter. Theidis thesvix-idthe endpoint received (msg_…).statusispendingbefore the first attempt,attemptingwhile one runs or a retry waits,successonce an attempt got a2xx, andfailedwhen none did and no more will run.GET /webhooks/:id/events/:eventIdaddsnext_attempt_atandpayload, the body as sent;.../attemptslists its attempts as{ "id", "http_status_code", "response", "sent_at" }, withhttp_status_code0when no response came back.POST /webhooks/:id/events/:eventId/replaysends the event once more now, with the same body andsvix-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:
| Event | On compatibility endpoints |
|---|---|
email.sent | yes |
email.scheduled | yes |
email.delivered | yes |
email.delivery_delayed | yes |
email.complained | yes |
email.bounced | yes |
email.opened | yes |
email.clicked | yes |
email.received | yes |
email.failed | yes |
email.suppressed | yes |
email.canceled | no |
contact.created | yes |
contact.updated | yes |
contact.deleted | yes |
domain.created | yes |
domain.updated | yes |
domain.deleted | yes |
broadcast.started | no |
broadcast.completed | no |
automation.completed | no |
automation.failed | no |
Deliveries are signed and shaped as Resend's (Verify signatures), so resend.webhooks.verify keeps working with the new secret:
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/receivingandGET /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,rawandattachments. The single email also hasauthentication:spf,dkimanddmarc, eachpass,fail,gray,processing_failedorunknown, from SES's verdicts.grayis SPFnone,softfailorneutral, DKIM unsigned or signed only by a domain outside the From domain's organizational domain, and DMARCnone. Resend also saysgrayfor DMARC underp=none, which SES's header does not show, so such mail readspasshere.GET /emails/receiving/:id/attachmentsand/attachments/:attachmentIdreturndownload_urlandexpires_at.- The
email.receivedwebhook carries the parsed content underdata.content(text,html,truncated) and the verdicts. There is nodata.text,data.htmlordata.headers, so a handler that reads those readsdata.contentinstead, or callsemails.receiving.get. resend.emails.receiving.forward()runs in the SDK: it downloadsraw.download_urland sends a new email withPOST /emails, so it needs a key that can send and afromon a verified domain, and it is an ordinary send, not a platform forward (no confirmation, no ARC).POST /emails/receiving/:id/forwardis 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_idis accepted as the older name ofsegment_id.createwithsend: truesends at once and, if the send is refused, leaves no draft behind.- Resend has three statuses:
draft,queued(scheduled, sending or paused here) andsent. duplicateanswers{ "object": "broadcast", "id" }with a draft.recipients(id, { type, email, bounceType })is the recipient list filtered byevent: each item hasid(a cursor, the contact id),contact_idandemail, pluscount,clicked_linksorbounce_typeas the type adds them.clickedLinks(id)isGET /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:
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 }),exitand theconditionconfig{ "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. removearchives the automation; afterwards it is not found.stopanswers{ "object": "automation", "id", "status": "disabled" }.duplicate(6.22.1) answers{ "object": "automation", "id" }with a draft that readsdisabled.- Runs read
running(also while waiting),completed(also exited),failedorcancelled(stopped); theirstepsstart with the trigger. /eventskeeps event definitions:events.gettakes 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 bothevents.sendandPOST /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 stillevents.send.- Not supported:
filter_ruleonwait_for_eventand 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_errorwhen 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 strictbatch.sendwith one such item fails whole. - Rate limits. 10 requests per second per key by default, reported in
ratelimit-limit,ratelimit-remainingandratelimit-reset.