Integrating · Published 2026-09-04 · Updated 2026-10-22 · AgentiSend
Migrating from Resend
What stays the same, how error names map, and what differs. Two things behave differently on purpose: the region default and enforced key budgets.
The request shape is the same
POST /emails, POST /emails/batch, GET/PATCH /emails/{id} and POST /emails/{id}/cancel accept Resend's request bodies, camelCase names included: from, to, subject, html, text, replyTo, topicId, scheduledAt and template all work with no rewrite. The full shape is on the POST /emails page.
To point the official Resend SDK here without changing your call sites, set the base URL and use an AgentiSend key:
# Node
RESEND_BASE_URL=https://api.agentisend.com// or pass it directly
const resend = new Resend(process.env.AGENTISEND_API_KEY, { baseUrl: 'https://api.agentisend.com' });# Python
RESEND_API_URL=https://api.agentisend.comErrors come back in our envelope (error.code, error.fix), not Resend's (name, statusCode) — see Error names resolve below. Batches are per-item by default, all-or-nothing with x-batch-validation: strict. Contacts, API keys, domains, topics, broadcasts, templates, segments and webhooks accept Resend's field names too, folded onto ours before anything validates. See What differs for the full list.
Headers
headers takes the same map of name to value. Two headers that bulk senders need pass through as you wrote them, when from is on a domain you have verified here:
headers: {
'List-Unsubscribe': '<https://acme.com/unsubscribe/abc>, <mailto:unsubscribe@acme.com>',
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
}List-Unsubscribetakes one or two links in angle brackets: anhttpslink, amailtolink, or one of each. Anhttplink is refused, and so is a value over 900 characters.List-Unsubscribe-Postmust be exactlyList-Unsubscribe=One-Click, and only next to anhttpslink, because a one-click unsubscribe posts to that link.- A send with a
topic_id, and every broadcast, already carries our own pair, which records the unsubscribe for you. On those sends our pair goes out, and the response'swarningssays the value you set was not used. In-Reply-ToandReferencesthread a reply to mail you received somewhere else, as in Resend's reply pattern. Each takes message IDs in angle brackets; the limits are in Receiving and replying.Message-IDpasses through whenfromis on a domain you have verified here, as Resend allows: one message ID, not onagentisend.com. When that domain is not verified, the refusal isdomain_not_verifiedanderror.codein the body is that code. The message names the header. The onboarding sender answersinvalid_parameterfor this header.- From, To, Subject, Date and the content headers are ours to write and are refused in
headers. So areX-Tenant,X-CampaignandX-Schedule, which route mail on its way out; put your own labels intags, or under anotherX-name.
Error names resolve
Every error body carries name, message and statusCode at the top level — the three fields the official Resend SDKs read off a refusal — beside our own envelope (code, fix, docs_url, retryable) nested under error. Resend's error names are accepted as aliases for our codes, so err.name === 'rate_limit_exceeded' or except RateLimitError still matches. The table is published in openapi.json under x-resend-error-names, generated from the same module the API resolves with, and every one of our 122 codes is in the error catalogue.
What our errors add is fix: a sentence naming the endpoint to call next, reached through error.fix. Code that branches on the top-level name keeps working; code that shows the user a message gets a better one for free.
The compatibility client, when the import line is all you want to change
The Node package ships a second entry point covering the email, domain, key, contact, segment, topic, broadcast, template, suppression, automation and webhook methods, with the same { data, error } return shape, so most of a Resend integration is the import line. There is no Python compat client yet; point resend-python at RESEND_API_URL instead.
import { Resend } from 'agentisend/compat-resend';
const resend = new Resend(process.env.AGENTISEND_API_KEY);
const { data, error } = await resend.emails.send({ from, to, subject, html });Where AgentiSend is stricter than Resend, the client warns on stderr instead of changing what it does silently. It is a bridge for the move, not the recommended long-term shape: the native client exposes the budget, the preflight and the kill switch, which the Resend surface has no words for. The exact coverage is enforced in CI (compat_resend_covers_every_resend_sdk_member) against every member of the published [email protected] package, recorded from the package itself — a member with no AgentiSend counterpart answers not_supported, never undefined.
Webhooks
Two settings turn a delivery Resend-shaped, both off unless asked for on a url endpoint: svix_compat: true adds the svix-id, svix-timestamp and svix-signature headers beside our own, so a verifier built for those headers keeps working unchanged, and payload_format: "resend" stores Resend's own payload shape (created_at, data.to, data.bounce.type/subType/diagnosticCode) instead of ours (occurred_at, bounce_type/diagnostic).
The compat client's webhooks.create sends both automatically, and so does endpoint (Resend's own field name for url) — svix_compat on its own. Ask for payload_format: "resend" explicitly next to endpoint if you also want the payload shape and are not going through the compat client. The create response has signing_secret (same value as secret) either way. POST /webhooks/{id}/signing-secret/rotate (or /rotate-secret) also returns signing_secret. PATCH /webhooks/{id} takes status: "disabled" or "enabled". Reads answer with endpoint and status beside our url and disabled. One difference is deliberate: the signing secret is shown once, on create and on rotate, and no later read returns it. Rotate the secret if you lose it.
Resend's event log works on the same paths: GET /webhooks/{id}/events lists what was sent to the endpoint with pending, attempting, success or failed, GET .../events/{event_id} adds the payload, GET .../events/{event_id}/attempts lists each attempt with the status code and body your endpoint answered, and POST .../events/{event_id}/replay sends one event again.
Templates
POST /templates takes the body Resend documents: name, html, alias, from, subject, reply_to (one address or a list), text and variables. A variable is { "key": "PRODUCT", "type": "string", "fallback_value": "item" }; one with no fallback must be given a value on every send, and a template holds up to 50. The native {name, type, required, default} form works too. from and reply_to are checked the way the same fields on POST /emails are, but the domain behind from is checked when you send, not when you save the template.
A template starts as a draft and sends only once it is published. GET, PATCH, DELETE, POST /templates/{id}/publish and POST /templates/{id}/duplicate take the template's id or its alias, and an alias is unique on your account. PATCH takes every field POST does. Editing a published template does not change what it sends: your change goes into a draft opened from the live version, which keeps sending until you publish the draft, and the template reads status: "published" with has_unpublished_versions: true while the draft waits. A name or alias change takes effect at once. Every published version stays readable, and POST /templates/{id}/rollback, GET /templates/{id}/diff and GET /templates/{id}/versions still work from there. GET shows the draft while one is open, so a call that edits a template reads back what it wrote.
To send one, name it by id or alias in template. Leave html and text out; a send that carries either next to a template is a validation error. from, subject and reply_to in the request replace the template's, and where the template has none you must send them.
await resend.emails.send({
to: 'delivered@resend.dev',
template: { id: 'order-confirmation', variables: { PRODUCT: 'Vintage Macintosh', PRICE: 499 } },
});The plain-text part is the one you wrote in text. Leave text out and one is made from the HTML; set it to an empty string to send no text part. {{{NAME}}} and {{NAME}} both fill a variable, and a value is escaped in the HTML either way. Variable types are string, number and boolean; the object and list types in Resend's schema are refused, and the variable names Resend reserves are not reserved here.
Contacts
POST /contacts accepts first_name, last_name and unsubscribed as well as properties. unsubscribed: true marks the contact unsubscribed and records that they asked not to receive marketing mail.
GET, PATCH and DELETE /contacts/{id} take the contact id or the email address. GET /contacts accepts after and before as page cursors. Send one of them, not both.
An audience is a segment. POST /audiences/{id}/contacts creates or updates the contact and adds them to that audience. GET /audiences/{id}/contacts lists the people in it, the same people as GET /segments/{id}/contacts. GET and PATCH /audiences/{id}/contacts/{id} read and edit a person who is in it. DELETE /audiences/{id}/contacts/{id} takes them out of the audience and leaves the contact on the account. An audience id from another account is refused and nothing is saved. A value in that position that is not an audience id still creates the contact and does not add them to an audience. POST, GET and DELETE /audiences (and /audiences/{id}) are the segment calls under the older name. A CSV column named audience_id, audience, segment_id or segment adds that row's contact to the audience it names.
POST /contacts/batch updates contacts that are already on the account, up to 1,000, and returns a result for each one. A new address is rejected. Add it with POST /contacts.
Segments
A segment's members are the contacts its rules match plus the contacts you add to it. POST /contacts/{id}/segments/{segment_id} adds one (the id may be the email address) and the contact stays in the segment, whatever the rules say, until DELETE on the same path removes it. Adding a contact twice changes nothing. A contact that is in the segment only because its rules match it cannot be removed this way: DELETE answers with what to change, which is the rules (PATCH /segments/{id}) or the contact. If a contact was both added and matches the rules, removing it takes away the addition and it stays a member through the rules. POST /contacts also takes segments: [{ "id": "..." }] and topics: [{ "id": "...", "subscription": "opt_in" }], applied once the contact is saved. An id that is not on your account is refused before anything is written.
Imports
POST /contacts/imports takes the multipart form Resend documents: the CSV in file, and column_map, on_conflict, segments and topics as JSON-encoded strings. It answers { "object": "contact_import", "id": "..." } with the counts and the rows it could not take, and GET /contacts/imports/{id} and GET /contacts/imports (with status, limit, after and before) read it back. A JSON body with the CSV text in csv works too, and finishes in the same response. column_map is Resend's: {"email":"Email","first_name":"First Name","properties":{"plan":{"column":"Plan","type":"string"}}}, the contact field on the left and the CSV column on the right. The older map from a CSV header to a field, {"Email Address":"email"}, still works. on_conflict defaults to skip.
A new address is created, as in Resend, with the same checks as POST /contacts. on_conflict decides only what happens to a contact that is already on your account. Every row that fails is listed in rejected with its CSV line, the address, and the reason and fix, so a bad row is never just a count. Differences from Resend, on purpose: one import takes at most 1,000 rows, the multipart upload is read up to 4 MB, and contacts that skip leaves unchanged are not added to the segments or topics you name.
Contact properties
POST /contact-properties takes key (letters, digits and underscores, up to 50 characters), type (string or number) and an optional fallback_value of that type. PATCH changes only the fallback_value; the key and type are fixed, as in Resend. DELETE removes the property definition. Values contacts already hold under that key stay on those contacts; set the value to null on a contact to remove it there. A broadcast uses the fallback_value for a contact with no value of its own. GET /contact-properties lists the declared properties and also the names your contacts already hold, which have no fallback_value.
A person signed in to the console marks a contact active again with POST /contacts/{id}/resubscribe and consent_source plus consent_at. An API key cannot lift an unsubscribe. A spam report is not lifted by that call.
The bounce return path
A domain that already sends from somewhere else often has an MX record on send. Creating it here without a return-path label then uses bounce instead of refusing the domain. To choose the label, pass custom_return_path (or return_path_subdomain) on POST /domains: up to 63 characters, starting with a letter. send is the default when that host is free. The label cannot be changed later; add another domain if it has to move. tls maps to tls_mode, and open_tracking / click_tracking (either casing) are stored on create.
What differs
| Resend | AgentiSend | ||
|---|---|---|---|
| Error envelope | name, message, statusCode at the top | The same three fields at the top (since Error names resolve), plus error.code and error.fix nested | |
| Batch | All-or-nothing | Per-item by default; x-batch-validation: strict for all-or-nothing, permissive for ids and errors together | |
| Tags | {name, value}[] on the way in and out | Accepted as {name, value}[] or a map; always returned as a map | |
| Management calls (contacts, API keys, domains, topics, broadcasts, templates) | Resend's field names | Both — Resend's names (first_name, domain_id, default_subscription, from, html, and the rest) are folded onto ours before validation | |
| Broadcast fields | from, reply_to, preview_text, topic_id; the Node SDK sends replyTo, previewText, topicId | All of them, both spellings, on create and on update; each is applied to every message at send | |
| Duplicating a broadcast | broadcasts.duplicate | POST /broadcasts/{id}/duplicate, and broadcasts.duplicate in the compat client | |
| Broadcast plain text | text beside html; leave it out and one is generated, send "" for none | The same. text takes the same `{{{name | fallback}}} placeholders as html and goes out as the plain-text part of every message. Read it back on GET /broadcasts/{id} with html, from and audience_id` |
| Create and send in one call | send: true on create; scheduled_at only with send | send: true runs the same checks as POST /broadcasts/{id}/send: a broadcast held for review, no postal address, a topic opt-out, a plan or daily limit, the operator stop. If a check refuses, the draft stays and the error names it. With send: true, scheduled_at takes an ISO time or a phrase such as "in 1 hour". Without send, a scheduled_at ISO time still saves the broadcast as scheduled, as it did before | |
| Who a broadcast reached | GET /broadcasts/{id}/recipients?type= and /clicked-links | Both, with Resend's parameters, paging and fields. type=unsubscribed is always an empty list: an unsubscribe is recorded for the contact, not for the message whose link was pressed, so no unsubscribe can be tied to one broadcast | |
| Email metrics | GET /emails/metrics with metrics, dimensions, granularity, timezone and id filters | The same parameters and response. Our own since, until and group_by still answer in the older shape; the two sets cannot be mixed in one call | |
| Domain capabilities | capabilities: {sending, receiving} on create and update, each enabled or disabled; sending defaults to enabled, receiving to disabled; at least one stays enabled | The same, read back as capabilities on every domain. Sending off refuses new mail from the domain and stops it being signed, and mail already waiting to go out from it fails. Receiving off, the default, makes the domain refuse inbound mail. See Capabilities | |
| Claiming a domain another account verified | POST /domains/claim, GET /domains/{id}/claim, POST /domains/{id}/claim/verify | The same three calls and the same fields. The move waits out 72 hours after your proof, and does not happen while the owner is sending. See Claiming a domain | |
| Shared email links | POST /emails/{id}/share with expires_in; up to 48 hours | The same, for a sent or a received email. The page is read-only: no script runs and nothing in the message loads. See Shared email links | |
| Segments | Resend's field names | name and audience_id as Resend sends them; rules in our shape. An empty filter is accepted; a filter with conditions is refused, because Resend does not publish what one looks like. Send rules instead | |
| Automations | steps with key, type and config, and connections; status; duplicate, stop, delete; runs | The same body and the same eight step types, read back with every connection's type filled in. The graph is checked when it is saved, not when it runs: one trigger, every step reachable, no loops, one path out of each step per connection type. stop leaves runs in progress to finish, as Resend documents; ?cancel_runs=true cancels them. A run that holds survives our restarts, and an email step is sent once per run. See Automations | |
| Response fields | Resend's names and type tags (object, endpoint, response_status, created_at, message_id, size) | Every field Resend's reference lists, beside ours, with six exceptions: filter on a segment (Resend publishes no shape for it; conditions come back as rules), signing_secret on a webhook read (shown once), request_body and response_body on a request log (never stored, because they hold recipients and content), revoked_reason on a revoked connection (none is recorded), and ai_credits on usage (there are none) | |
| Attachment ids and downloads | uuid ids; download_url and expires_at on the attachments calls | The same: uuid ids that never change, and a download_url that needs no API key for one hour. The old ids 0, 1 still work. GET …/attachments/{id} returns the file, or its JSON view when the call asks for JSON, as the Resend SDKs do | |
| Cancelling an email | Answers with the email | The same email, as GET /emails/{id} shows it | |
| Received mail | to is the message's To list | to is the address on your domain the copy was delivered to, a single string. The message's own To list is in headers.to on the single read | |
| Webhooks | Svix-signed, Resend's payload shape | Off by default; svix_compat and payload_format: "resend" turn both on — see Webhooks | |
| Region | Not a concept | us or eu, defaults to us — see below | |
| Deleting a broadcast | Deletes a draft | Deletes a draft too; a scheduled or sent one is refused with the archive call to use instead, so its messages stay readable | |
| Custom events | POST /events declares, POST /events/send sends | Both, and /events/{id} takes the event's name as well as its id. A POST /events body without schema from your own code still sends the event, as it always has here |
Metrics are counted from the event log, and four things differ from what Resend's page implies. Simulator sends are not counted (three test bounces are not a 75% bounce rate). sent counts every address a message went to, the same unit as delivered and bounced, so the rates divide like by like. unsubscribed is counted for the account over the period; it is reported as 0 whenever you ask for a split by domain, email or broadcast, because the unsubscribe cannot be attributed to any of them. Weeks start on Monday, and period labels are the local date (2026-03-04), or the local date and hour (2026-03-04T13:00:00) for hourly. Resend's page does not state the week start or the hourly label. A bare end_date covers that whole day, as Resend's tip about single-day queries implies, and the response echoes it as midnight UTC, as Resend's example does.
Region is optional and defaults to `us`. Omitting it stores this domain as us (Oregon); pass eu for eu recording — today all mail is still sent from our US nodes, and eu domains move to EU sending automatically when it ships. See Domains and DNS. Tracking clicks and opens uses a host on *your* domain, never a shared one.
Keys have budgets, and the budget is enforced. A key with a ceiling stops at the ceiling rather than continuing and invoicing you. If you port a key that sends heavily, set its budget to match your real volume before you cut over, or the first thing the migration does is stop.
Capabilities
capabilities takes sending and receiving, each enabled or disabled. A new domain sends and does not receive. An update names one or both and the other keeps its value. A request that would leave both disabled is refused, and nothing changes. Turn sending off and a send from the domain is refused with domain_sending_disabled, mail from it that was already waiting fails instead of going out, and the domain stops being signed. Turn receiving on and the domain accepts inbound mail once its MX points at our receiving host. The Receiving record appears on the domain once a receiving host is published for your account; until then the switch only tells us to accept the domain's mail. Domains that existed before this change keep receiving on.
Claiming a domain
A domain can sit verified on one account only. When POST /domains or a verify answers that another account has it, start a claim with POST /domains/claim and the domain name. You get a placeholder domain on your account, with its own DKIM key, and a TXT record to publish at the domain. Then call POST /domains/{id}/claim/verify with the placeholder's id, and read GET /domains/{id}/claim until status is completed. The statuses are Resend's: pending, verified, completed, blocked, expired, superseded, canceled and failed. A claim lasts 7 days. Deleting the placeholder cancels it. After it completes the domain is yours but pending: publish the new DKIM record and verify it as usual.
The DNS proof is necessary and never enough. The TXT value is random for each claim and is read from the domain's own name servers. When it is first seen, the account that holds the domain is told, by notification and in its audit log, and these checks run before anything moves:
- `grace_period`. The move waits 72 hours from that first proof.
- `recent_owner_activity`. It does not happen if the owner sent mail from the domain, or received mail at it, in the last 30 days.
- `pending_scheduled_emails`. It does not happen while the owner has scheduled or queued mail from the domain, or a scheduled broadcast sending from it.
A blocked claim stays open: call verify again and it is checked again, from the start. Resend states these three reasons and does not publish their lengths; the 72 hours and 30 days are ours, chosen to be the longer reading. Domains that belong to us, and domains an operator has blocked, are never moved. Only an account in good standing can claim. All of it is checked again, with the owner's domain locked, in the step that moves it. Verify answers with where the claim ended up rather than pending while it runs, so a client that polls until completed works as written.
Shared email links
POST /emails/{id}/share takes expires_in as a number and a unit: 10m, 2 hours, 1 day. The default and the longest is 48 hours; a longer time, or a bare number, is refused. The answer is {"object": "email", "id": "...", "url": "..."}. The id can be a sent email or a received one. Anyone with the link can read that one message until it expires, with no sign-in, so share it as you would the message itself. The page shows who it was from and to (never Bcc), the date, and the body in a frame that cannot run script, load an image, follow a link or send anything to the sender, so opening it is not counted as an open. Search engines are told not to keep it. The link stops working at expiry, when the email is deleted or purged under your retention window, and while the account is suspended. Only a hash of the link is stored.
What you gain in the move
- Per-key budgets and one kill switch for the account — Budgets and the kill switch.
- An approval queue for a send the loop guard flags, or one an agent asks about with request_approval — Approvals.
- Published enforcement thresholds and an appeal path — Trust and enforcement.
- Bounce classification with the messages behind each class — Deliverability.
Order of operations
- Add and verify your domains here while the old provider still sends.
- Create keys with budgets, one per sender, and set up webhooks.
- Move a low-volume stream first and watch the reputation panel for a few days.
- Move the rest, and keep the old provider's keys alive until you have a week of clean data.