
Resend Api
- 11 installs
- 3 repo stars
- Updated June 29, 2026
- tristanmanchester/agent-skills
Helps with ai & agent building tasks during AI-assisted development.
About
resend-api is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- resend-api
- AI & Agent Building
- AI-coding skill
Resend Api by the numbers
- 11 all-time installs (skills.sh)
- Ranked #11,769 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/tristanmanchester/agent-skills --skill resend-apiAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 11 |
|---|---|
| repo stars | ★ 3 |
| Last updated | June 29, 2026 |
| Repository | tristanmanchester/agent-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Resend API
Use this skill for tasks that specifically involve Resend's API, SDKs, webhook model, receiving API, or official agent-facing tooling. Do not use it for generic email-platform advice unless the user is clearly working with Resend.
Read only what you need
Start with the smallest relevant file instead of loading everything.
references/core-reference.md— auth, headers, rate limits, pagination, idempotency, error triagereferences/sending-and-templates.md— transactional sends, batch sends, scheduling, templatesreferences/domains-and-api-keys.md— verified domains, DNS records, regions, tracking, key scopereferences/contacts-broadcasts-and-subscriptions.md— contacts, segments, topics, properties, broadcastsreferences/webhooks-inbound-and-beta.md— webhook verification, receiving, replay/retry, workflows, eventsassets/endpoint-catalog.json— compact stable-endpoint catalogue plus beta notesassets/resend-openapi.yaml— raw stable OpenAPI snapshot for deeper schema inspectionassets/*.json— reusable payload templates for common operations
Quick routing
Choose the product primitive before generating code or making live calls.
1. Single transactional email → POST /emails 2. Many distinct sends in one call → POST /emails/batch 3. Campaign to a segment or list → Broadcasts + Segments + Topics 4. Reusable content → Templates 5. Verified sender domain or receiving domain → Domains 6. Scoped credentials → API Keys 7. Subscriber model and profile data → Contacts, Topics, Contact Properties, Segments 8. Inbound email processing → Receiving Emails API + email.received webhook 9. Event delivery to your app → Webhooks 10. Custom event-driven automations → Workflows + Events, but treat them as beta/private alpha
Workflow
1) Identify the job type
Classify the request first:
- Code generation — add or edit Resend integration code in an existing project
- Live API execution — make a real call against a Resend account
- Debugging — explain an error, fix a payload, or diagnose a failed flow
- Architecture — choose between Resend features and design the right flow
2) Choose the best execution surface
Prefer the most native surface for the user's environment:
- If the project already uses an official Resend SDK, generate or modify code in that language.
- If the user wants a reproducible example or is stack-agnostic, prefer raw REST or cURL.
- If the environment already has the official Resend MCP server installed, it is fine to use it
for live operations, but still follow the payload and workflow guidance in this skill.
3) Before any live request
Always do these checks for real API calls:
1. Load references/core-reference.md. 2. Confirm RESEND_API_KEY is present. 3. Use python3 scripts/resend_api.py schema METHOD PATH if you need an offline schema summary. 4. Use python3 scripts/resend_api.py request ... or an equivalent HTTP client for the live call. 5. Include Authorization: Bearer ... and a User-Agent. 6. For POST /emails and POST /emails/batch, add an Idempotency-Key before any retry. 7. Avoid automatic retries on unsafe mutations unless idempotency is in place.
4) Generate code or payloads
When writing code or examples:
- Keep them minimal, runnable, and explicit about required environment variables.
- Use absolute ISO 8601 timestamps for scheduling.
- Call out feature limits that affect the request shape.
- Mention the exact endpoint(s), payload keys, and next verification step.
- Use the sample payload files in
assets/as a starting point when helpful.
5) Debug in the right order
When something fails, inspect these first:
1. Wrong or missing API key 2. Missing User-Agent on raw HTTP 3. Unverified or test-only sender domain 4. Retried send without idempotency 5. Invalid attachment or from-address format 6. Batch limitations (no attachments, no scheduling) 7. Template not published before send 8. Expecting full inbound message content directly inside the webhook payload
6) Special handling rules
- Scheduling: use an exact timestamp and mention the 72-hour limit.
- Templates: publish before sending; if a template is used, do not also send raw
htmlortext. - Subscriptions: prefer Topics + Segments over deprecated Audiences.
- Receiving: treat the webhook as the trigger; fetch the full message and attachments via the
receiving endpoints.
- Webhooks: verify the signature against the raw request body before parsing JSON.
- Workflows/Events: explicitly label them as beta/private alpha and confirm availability before
proposing them as production-critical building blocks.
Bundled tools
scripts/resend_api.py
The bundled helper script is designed for agents:
catalog— list stable endpoints from the bundled catalogueschema— show a compact schema/parameter summary for an endpointrequest— make a live request with auth, user-agent, JSON parsing, optional pagination, and
cautious retry behaviour
Examples:
python3 scripts/resend_api.py catalog --group Emails
python3 scripts/resend_api.py schema POST /emails
python3 scripts/resend_api.py request GET /domains
python3 scripts/resend_api.py request POST /emails --json-file assets/send-email.json --idempotency-key welcome-001
python3 scripts/resend_api.py request GET /emails --paginate --page-limit 3Common pitfalls
- Using
/emails/batchwhen attachments or scheduling are required - Forgetting that
/emailsis the right primitive for one logical email, even whentois an array - Generating template-send code before the template has been published
- Using deprecated Audiences for new subscriber flows
- Expecting inbound webhooks to include the full raw message or attachment bytes
- Treating beta workflow/event APIs as stable
- Ignoring the default per-team rate limit and flooding the API with parallel requests
Output expectations
When this skill is active, return:
1. The exact endpoint(s) involved 2. The minimal payload or code needed 3. The operational caveats that matter for this task 4. The next verification step, such as listing resources, confirming DNS records, or replaying a webhook
Example prompts this skill should handle
- “Add Resend to this Next.js app and send a scheduled password-reset email”
- “Why is my raw Resend API call returning 403?”
- “Create a verified sending domain in eu-west-1 and turn receiving on”
- “Set up topic-based newsletter subscriptions with contacts and broadcasts”
- “Build an inbound email webhook for support@”
- “Should I use batch sends, broadcasts, or templates for this flow?”
- “Can Resend workflows wait for a custom event and then send follow-ups?”
{
"name": "mailer-prod-eu",
"permission": "sending_access",
"domain_id": "d_123456789"
}{
"name": "march-launch",
"segment_id": "seg_123456789",
"from": "Acme News <news@example.com>",
"subject": "March product update",
"preview_text": "A quick look at what shipped this month",
"html": "<h1>March updates</h1><p>Lots of new things landed.</p>",
"topic_id": "top_123456789",
"send": false
}{
"email": "ada@example.net",
"first_name": "Ada",
"last_name": "Lovelace",
"unsubscribed": false,
"properties": {
"plan_tier": "pro",
"account_age_days": 365
},
"segments": [
"seg_123456789"
],
"topics": [
{
"id": "top_123456789",
"subscription": "opt_in"
}
]
}{
"name": "example.com",
"region": "eu-west-1",
"open_tracking": true,
"click_tracking": true,
"tls": "enforced",
"capabilities": {
"sending": "enabled",
"receiving": "enabled"
}
}{
"endpoint": "https://api.example.com/resend/webhooks",
"events": [
"email.sent",
"email.delivered",
"email.bounced",
"email.received"
]
}[
{
"from": "Acme <orders@example.com>",
"to": "ada@example.net",
"subject": "Your order has shipped",
"html": "<p>Your order 1001 has shipped.</p>",
"tags": [
{
"name": "flow",
"value": "shipping"
}
]
},
{
"from": "Acme <orders@example.com>",
"to": "grace@example.net",
"subject": "Your order has shipped",
"html": "<p>Your order 1002 has shipped.</p>",
"tags": [
{
"name": "flow",
"value": "shipping"
}
]
}
]{
"from": "Acme <onboarding@example.com>",
"to": "user@example.net",
"subject": "Welcome to Acme",
"template": {
"id": "tpl_123456789",
"variables": {
"first_name": "Ada",
"plan_name": "Pro"
}
}
}{
"from": "Acme <onboarding@example.com>",
"to": [
"user@example.net"
],
"subject": "Welcome to Acme",
"html": "<p>Hi Ada, welcome aboard.</p>",
"text": "Hi Ada, welcome aboard.",
"tags": [
{
"name": "flow",
"value": "welcome"
}
]
}{
"skill_name": "resend-api",
"evals": [
{
"id": 1,
"prompt": "Add Resend to my Next.js app and schedule a password reset email for tomorrow at 09:00 Berlin time.",
"expected_output": "A runnable Resend example that uses the correct send primitive, an ISO 8601 scheduled timestamp, idempotency guidance, and a note about scheduling limits.",
"assertions": [
"The response chooses POST /emails rather than batch or broadcast",
"The response includes a concrete ISO 8601 scheduled timestamp example",
"The response mentions idempotency for the send request",
"The response mentions that scheduled sends have a 72 hour ceiling"
]
},
{
"id": 2,
"prompt": "I need to send 70 different shipment notifications with slightly different content. Which Resend endpoint should I use?",
"expected_output": "A recommendation to use batch sends, including the 100-email limit and the restrictions that matter for batch sends.",
"assertions": [
"The response chooses POST /emails/batch",
"The response mentions the 100-email per request limit",
"The response warns that attachments are not supported in batch sends",
"The response warns that scheduled_at is not supported in batch sends"
]
},
{
"id": 3,
"prompt": "Create a verified Resend domain for eu.example.com in the EU region and turn receiving on as well.",
"expected_output": "A domain-creation workflow with the correct region, receiving capability, DNS verification steps, and a note about MX/subdomain considerations.",
"assertions": [
"The response chooses the Domains API",
"The response mentions eu-west-1 as the EU region value",
"The response mentions sending and receiving capabilities",
"The response includes a DNS verification step"
]
},
{
"id": 4,
"prompt": "Set up inbound email processing for support@example.com with signature verification.",
"expected_output": "A webhook + receiving API design that verifies the raw body signature and then fetches the full message body/attachments through receiving endpoints.",
"assertions": [
"The response mentions the email.received webhook event",
"The response says verification must use the raw body",
"The response mentions svix-id, svix-timestamp, and svix-signature",
"The response explains that the webhook payload does not contain the full message body or attachments"
]
},
{
"id": 5,
"prompt": "I want a reusable welcome email template in Resend and then I want to send it with variables.",
"expected_output": "A template lifecycle that creates, publishes, and then sends the template with variables, without mixing template and raw HTML/text in the same send payload.",
"assertions": [
"The response includes a template publish step",
"The response uses POST /emails to send the published template",
"The response says not to send html or text alongside template usage",
"The response warns about reserved variable names"
]
},
{
"id": 6,
"prompt": "Help me model newsletter subscriptions in Resend with contacts, topics, properties, segments, and broadcasts.",
"expected_output": "A coherent subscription architecture that prefers segments over deprecated audiences and uses the right layer for global unsubscribes, topic preferences, and campaign targeting.",
"assertions": [
"The response prefers Segments over Audiences",
"The response distinguishes global unsubscribed from topic subscriptions",
"The response includes Contact Properties as typed profile data",
"The response uses Broadcasts for campaign delivery"
]
},
{
"id": 7,
"prompt": "My raw Resend REST call returns 403. What should I check first?",
"expected_output": "A debugging checklist that inspects API key validity, User-Agent presence, and sender-domain verification before moving on.",
"assertions": [
"The response mentions invalid or missing API keys",
"The response mentions the required User-Agent for raw HTTP",
"The response mentions unverified or test-only sender domains",
"The response does not jump straight to unrelated webhook advice"
]
},
{
"id": 8,
"prompt": "Can Resend wait for my custom event and then send a follow-up email automatically?",
"expected_output": "An explanation of the Workflows + Events beta/private-alpha model, including the need to confirm account availability before treating it as production-ready.",
"assertions": [
"The response mentions Workflows and Events",
"The response says they are beta or private alpha",
"The response mentions a wait_for_event step or event-driven workflow concept",
"The response explicitly tells the user to confirm availability before relying on it"
]
}
]
}Contacts, broadcasts, and subscriptions
Prefer Segments over Audiences
Resend still exposes Audiences in the stable API, but the docs and schema mark them as deprecated. For new work, prefer:
- Contacts
- Contact Properties
- Topics
- Segments
- Broadcasts
Contacts
Stable contact endpoints:
POST /contactsGET /contactsGET /contacts/{id}PATCH /contacts/{id}DELETE /contacts/{id}GET /contacts/{contact_id}/segmentsPOST /contacts/{contact_id}/segments/{segment_id}DELETE /contacts/{contact_id}/segments/{segment_id}GET /contacts/{contact_id}/topicsPATCH /contacts/{contact_id}/topics
Notable contact fields:
email— required on createfirst_namelast_nameunsubscribed— global opt-out across broadcastsproperties— custom key/value mapsegments— initial segment IDs to attachtopics— per-topic opt-in/opt-out array
GET /contacts/{id} can retrieve by contact ID or email, which is handy in support or debugging flows.
Contact Properties
Use Contact Properties to define typed profile fields before relying on them in filters or broadcast logic.
Stable endpoints:
POST /contact-propertiesGET /contact-propertiesGET /contact-properties/{id}PATCH /contact-properties/{id}DELETE /contact-properties/{id}
Rules from the stable schema:
keymax length: 50- key characters: alphanumeric or underscore
type:stringornumberfallback_valuemust match the declared type
Topics
Topics control subscription semantics and unsubscribe-page visibility.
Stable endpoints:
POST /topicsGET /topicsGET /topics/{id}PATCH /topics/{id}DELETE /topics/{id}
Topic creation fields:
name— required, max 50 charsdefault_subscription— required,opt_inoropt_outdescription— optional, max 200 charsvisibility—publicorprivate, defaultprivate
Important note: default_subscription cannot be changed after creation.
Segments
Segments describe a filtered group of contacts and are the preferred broadcast target.
Stable endpoints:
POST /segmentsGET /segmentsGET /segments/{id}DELETE /segments/{id}
Create fields:
name— requiredfilter— object representing the segment conditionsaudience_id— deprecated
Use segments for marketing/newsletter cohorts, lifecycle buckets, or property-based recipient groups.
Broadcasts
Broadcasts are the campaign layer on top of Contacts/Segments/Topics.
Stable endpoints:
POST /broadcastsGET /broadcastsGET /broadcasts/{id}PATCH /broadcasts/{id}DELETE /broadcasts/{id}POST /broadcasts/{id}/send
Important request fields:
segment_id— required for new workfrom— requiredsubject— requiredhtml/textreply_topreview_texttopic_idsendscheduled_at
Useful patterns:
- create a draft broadcast with
send: false - review or patch it
- call
/broadcasts/{id}/sendwhen ready
Deletion rule from the stable endpoint summary: only broadcasts still in draft status are expected to be removable.
Subscription model guidance
Use the right layer for the right purpose:
- Global unsubscribe → contact-level
unsubscribed - Category/topic consent → contact topic subscriptions
- Campaign audience → segments
- Profile data → contact properties
When the user describes “newsletter preferences”, “marketing categories”, or “product update opt-in”, that is usually a Topics + Contacts + Broadcasts design, not a transactional send design.
Minimal example flow
1. create a Topic for “product-updates” 2. create a Contact Property such as plan_tier 3. create/update Contacts with properties and topic subscriptions 4. create a Segment filtered on the contact data 5. create a Broadcast to the Segment, optionally scoped to the Topic 6. send immediately or schedule it
Useful assets
assets/create-contact.jsonassets/create-broadcast.json
Core reference
Transport basics
- Base URL:
https://api.resend.com - Authentication:
Authorization: Bearer $RESEND_API_KEY - Raw HTTP integrations should send a
User-Agentheader. - Use JSON request bodies unless the endpoint explicitly returns or expects binary content.
Preferred execution surfaces
Choose the simplest surface that matches the task:
1. Official SDK already in project — extend that codebase in-place. 2. One-off example or debugging — use REST or cURL. 3. Agent environment with Resend MCP — acceptable for live operations, but still follow the request-shape and safety rules in this skill. 4. Offline discovery or careful live calls — use scripts/resend_api.py.
Rate limiting
Resend documents a default per-team limit of 2 requests per second. For loops, migrations, or bulk operations, build in pacing or backoff instead of firing unbounded parallel requests.
Cursor pagination
List endpoints use cursor pagination:
limit: 1 to 100, default 20after: fetch the page after a given object IDbefore: fetch the page before a given object ID- never send
afterandbeforetogether
Typical list responses look like:
{
"object": "list",
"has_more": true,
"data": [{ "id": "..." }]
}If you are paginating forward, use the last object's id as the next after cursor.
Stable endpoint groups
Use assets/endpoint-catalog.json or python3 scripts/resend_api.py catalog for the full list. The stable OpenAPI snapshot covers:
- Emails
- Receiving Emails
- Domains
- API Keys
- Templates
- Contacts
- Segments
- Topics
- Contact Properties
- Broadcasts
- Webhooks
- Audiences (deprecated)
Safety rules for mutations
- For
POST /emailsandPOST /emails/batch, set anIdempotency-Keybefore retrying. - Do not blindly retry
POST,PATCH, orDELETEunless the operation is demonstrably idempotent
or guarded with an idempotency key.
- Prefer
--dry-runor schema inspection before destructive changes.
Error triage cheat sheet
Start here before blaming the entire integration.
400
invalid_idempotency_key— malformed or oversized idempotency key
401
missing_api_key— forgot the bearer tokenrestricted_api_key— using a sending-only key for a broader operation
403
invalid_api_key— wrong or expired key- missing or blocked
User-Agenton raw HTTP - unverified sender domain or test-mode restriction
404
- wrong resource ID
- wrong path or wrong environment assumptions
409
invalid_idempotent_request— same idempotency key reused with a different payloadconcurrent_idempotent_requests— same key still in-flight
422
- invalid attachment payload
- invalid from-address format
- invalid access pattern or incompatible body fields
Live-call checklist
Before making a real call, confirm all of the following:
RESEND_API_KEYis loaded from the environment- the sender domain is verified if the endpoint involves sending
- the request includes
AuthorizationandUser-Agent - the path and method match the intended endpoint
- the body obeys endpoint-specific restrictions
- the follow-up verification step is known in advance
Useful assets
assets/endpoint-catalog.json— compact endpoint and schema indexassets/resend-openapi.yaml— raw stable OpenAPI snapshotassets/send-email.json— simple transactional send payloadassets/create-domain.json— verified-domain creation payload
Domains and API keys
Domain lifecycle
Use Domains whenever the task involves verified senders, DNS setup, regional routing, or inbound mail.
Stable domain endpoints:
POST /domainsGET /domainsGET /domains/{domain_id}PATCH /domains/{domain_id}DELETE /domains/{domain_id}POST /domains/{domain_id}/verify
Create-domain request options
The stable schema supports these important fields:
name— requiredregion— one of:us-east-1eu-west-1sa-east-1ap-northeast-1custom_return_path— advanced return-path subdomainopen_tracking— booleanclick_tracking— booleantls—opportunisticorenforcedcapabilities:sending:enabledordisabledreceiving:enabledordisabled
At least one domain capability should be enabled.
DNS records
Domain objects return DNS records to configure. Stable record types include:
- SPF
- DKIM
- Receiving
Returned DNS record types may be:
TXTCNAMEMX
Record statuses typically move through:
not_startedpendingverifiedfailedtemporary_failure
Recommended operational flow:
1. create the domain 2. apply the returned DNS records with your DNS provider 3. wait for propagation 4. call /domains/{domain_id}/verify 5. retrieve the domain until all records verify
Receiving-capable domains
If the user wants inbound email on a custom domain:
- enable the domain's receiving capability
- configure the MX records returned by Resend
- if the root domain already has important MX records, prefer a dedicated subdomain such as
inbound.example.com or mail.example.com
Tracking and TLS updates
Use PATCH /domains/{domain_id} to update:
open_trackingclick_trackingtlscapabilities
This is useful when the user wants to enable or disable tracking programmatically or switch TLS mode after the initial setup.
API keys
Stable key endpoints:
POST /api-keysGET /api-keysDELETE /api-keys/{api_key_id}
Create-key request fields:
name— requiredpermission—full_accessorsending_accessdomain_id— optional restriction when usingsending_access
Important behaviour:
- the secret token is returned when the key is created
- store it immediately; do not assume it can be retrieved later from a list endpoint
- use
sending_accessplusdomain_idfor least-privilege sending agents where possible
Practical guidance
When to create a domain-scoped sending key
Use a domain-scoped sending_access key when:
- an agent only needs to send mail
- the sender must be constrained to one domain
- you want to reduce blast radius compared with a
full_accesskey
When to use full_access
Use full_access only if the workflow genuinely needs to manage domains, contacts, webhooks, broadcasts, or other resources beyond sending.
Minimal examples
Create a domain
python3 scripts/resend_api.py request POST /domains --json-file assets/create-domain.jsonCreate a least-privilege sending key
python3 scripts/resend_api.py request POST /api-keys --json-file assets/create-api-key.jsonDebug checklist
If domain setup is blocked:
1. inspect the returned DNS records 2. confirm the DNS provider copied names and values exactly 3. verify MX priority values where applicable 4. call the verify endpoint again after propagation 5. check whether sending vs receiving capability matches the intended use
Useful assets
assets/create-domain.jsonassets/create-api-key.json
Sending and templates
Choose the right sending primitive
POST /emails
Use this for one logical email send.
Good fit for:
- transactional emails
- one message to up to 50 direct recipients
- sends that require attachments
- sends that require
scheduled_at - sends scoped to a
topic_id - sends that use a published template
Important request fields:
- required:
from,to,subject - content:
html,text, ortemplate - optional:
cc,bcc,reply_to,headers,attachments,tags,scheduled_at,topic_id
POST /emails/batch
Use this for many distinct email objects in one API call.
Good fit for:
- personalised transactional messages for many recipients
- queueing up to 100 separate email payloads per request
Important restrictions:
attachmentsare not supportedscheduled_atis not supported- use an
Idempotency-Keywhen retries are possible
Broadcasts
Use Broadcasts when the target is a segment/list, not a one-off transactional send.
Broadcast flow:
1. create or update Contacts 2. define Topics and Contact Properties if needed 3. define a Segment 4. create a Broadcast 5. send immediately or schedule it
Broadcast request notes:
- required:
from,subject,segment_id send: truecan send immediatelyscheduled_atcan be used whensendis truetopic_idscopes the unsubscribe and topic behaviour
Scheduling rules
Resend documents these constraints for scheduled sends:
- timestamps must be ISO 8601
- scheduled sends can be at most 72 hours in the future
- cancelled scheduled emails cannot be rescheduled
- scheduled sends do not support:
- batch emails
- SMTP sends
- emails with attachments
Use /emails/{email_id}/cancel to cancel a scheduled email. A PATCH /emails/{email_id} endpoint exists for updating a scheduled email, but check the latest schema/docs before assuming which fields are mutable.
Attachments
For transactional POST /emails:
- attachment payloads may include file content, filename, hosted path, content type, and content ID
- total attachment size is constrained by Resend's documented 40 MB limit after base64 encoding
- if you expect binary responses when retrieving attachments, prefer
--output FILEwith the
bundled script
Tags
Tags are useful for downstream analytics and event handling.
Tag rules from the stable schema:
nameandvaluemay contain ASCII letters, numbers, underscores, or dashes- each field can be at most 256 characters
Template lifecycle
Templates are draft resources until published.
Recommended flow:
1. POST /templates — create draft 2. PATCH /templates/{id} — refine it 3. POST /templates/{id}/publish — publish it 4. POST /emails with template.id or template alias logic in your own app
Template sending rules:
- if you use
template, do not also send rawhtmlortext - the template must already be published
from,subject, andreply_toin the email send payload override template defaults
Template variables
Documented constraints to keep in mind:
- keys should be kept small and predictable
- values on send are string/number pairs in the send payload
- Resend documents reserved variable names on template/send docs; avoid these across templates and
payloads:
FIRST_NAMELAST_NAMEEMAIL- unsubscribe-related reserved names
contactthis
When in doubt, use application-specific keys such as customer_name, cta_url, plan_name.
Minimal examples
cURL single send
curl -X POST https://api.resend.com/emails -H "Authorization: Bearer $RESEND_API_KEY" -H "User-Agent: my-app/1.0" -H "Content-Type: application/json" -H "Idempotency-Key: password-reset-001" -d @assets/send-email.jsonNode.js SDK single send
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY);
await resend.emails.send({
from: "Acme <onboarding@example.com>",
to: "user@example.net",
subject: "Welcome to Acme",
html: "<p>Hi Ada, welcome aboard.</p>",
});Batch send via bundled helper
python3 scripts/resend_api.py request POST /emails/batch --json-file assets/send-batch.json --idempotency-key shipping-batch-001Common send-debug checklist
If a send is failing:
1. confirm the sender domain is verified 2. confirm the request includes User-Agent 3. confirm batch is not being used for attachments or scheduling 4. confirm template sends are using a published template 5. confirm idempotency keys are not being reused with different payloads 6. inspect 422 details for address, access, or attachment problems
Useful assets
assets/send-email.jsonassets/send-email-template.jsonassets/send-batch.jsonassets/create-broadcast.json
Webhooks, inbound email, and beta APIs
Webhooks
Stable webhook endpoints:
POST /webhooksGET /webhooksGET /webhooks/{webhook_id}PATCH /webhooks/{webhook_id}DELETE /webhooks/{webhook_id}
Create-webhook essentials:
endpoint— requiredevents— required array with at least one event type
The create/retrieve/list flow returns a signing_secret. Keep it secure and use it to verify every incoming webhook before trusting the payload.
Verify signatures before parsing
Recommended verification approach:
1. read the raw request body bytes exactly as received 2. extract the Svix headers:
svix-idsvix-timestampsvix-signature
3. verify the signature with the webhook signing secret 4. only then parse the JSON payload
If the raw body is altered before verification, signature checks can fail.
Event types to expect
The docs show webhook coverage for email, domain, and contact events. Common email events include:
email.sentemail.deliveredemail.delivery_delayedemail.failedemail.bouncedemail.complainedemail.openedemail.clickedemail.scheduledemail.suppressedemail.received
Also expect domain/contact lifecycle events in webhook-capable accounts.
Retry and replay behaviour
Resend documents automatic retries plus dashboard/manual replay support.
Operational guidance:
- make webhook handlers idempotent
- log the webhook event ID and affected resource ID
- return a fast 2xx once the event is durably queued
- use replay when backfilling or recovering from handler outages
Inbound / receiving email
Receiving can use either:
- a
*.resend.appdomain - a custom domain/subdomain you control
Recommended flow:
1. enable receiving on a domain 2. point the MX records as instructed by Resend 3. create a webhook subscribed to email.received 4. when a webhook fires, fetch the full message and attachments through the receiving endpoints
Important nuance: the inbound webhook payload contains metadata, not the full email body, headers, or attachment bytes. To get the actual content, call:
GET /emails/receivingGET /emails/receiving/{email_id}GET /emails/receiving/{email_id}/attachmentsGET /emails/receiving/{email_id}/attachments/{attachment_id}
If the receiving domain is a root domain that already depends on existing MX records, prefer a dedicated subdomain for inbound routing.
Sent-email attachments
For sent email inspection or audits, the stable API also exposes:
GET /emails/{email_id}/attachmentsGET /emails/{email_id}/attachments/{attachment_id}
Use --output FILE with the bundled helper when retrieving binary attachment content.
Beta: Workflows and Events
The Resend docs describe Workflows and Events as private alpha / beta functionality. Treat them as unstable until the user's account and SDK version are confirmed.
Workflows
Documented workflow notes include:
- create workflows via
POST /workflows - workflow shape includes
name,status,steps, andedges - documented step types include:
triggersend_emaildelaywait_for_eventcondition- workflow runs can be inspected with:
GET /workflows/{workflow_id}/runsGET /workflows/{workflow_id}/runs/{run_id}
Events
Documented event notes include:
- create events via
POST /events - events can define a flat schema
- schema field types include
string,number,boolean, anddate - dot notation is recommended for event names such as
cart.abandoned
Preview SDK note
The docs mention a preview Node SDK version for workflows/events:
resend@6.10.0-preview-workflows.1
Do not silently generate production rollout plans around workflows/events without explicitly stating their pre-GA status.
Minimal webhook example flow
1. create the webhook 2. persist the signing secret 3. verify signatures against the raw body 4. make handler processing idempotent 5. for inbound events, fetch the full message after verification
Useful assets
assets/create-webhook.jsonassets/endpoint-catalog.json(includes beta notes)
#!/usr/bin/env python3
"""Resend API helper for agent skills.
Features:
- List endpoints from the bundled catalogue
- Inspect a compact schema summary for an endpoint
- Make live API calls with auth, user-agent, JSON parsing, cautious retries, and optional pagination
Environment:
- RESEND_API_KEY Required for `request`
- RESEND_BASE_URL Optional, defaults to https://api.resend.com
- RESEND_USER_AGENT Optional, defaults to resend-api-skill/1.0
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
SCRIPT_DIR = Path(__file__).resolve().parent
SKILL_ROOT = SCRIPT_DIR.parent
CATALOG_PATH = SKILL_ROOT / "assets" / "endpoint-catalog.json"
DEFAULT_BASE_URL = "https://api.resend.com"
DEFAULT_USER_AGENT = "resend-api-skill/1.0"
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
RETRYABLE_STATUSES = {429, 500, 502, 503, 504}
def load_catalog() -> Dict[str, Any]:
with CATALOG_PATH.open("r", encoding="utf-8") as f:
return json.load(f)
def iter_endpoints(catalog: Dict[str, Any], include_beta: bool = False) -> Iterable[Dict[str, Any]]:
for tag in catalog.get("tags", []):
for endpoint in tag.get("endpoints", []):
item = dict(endpoint)
item["group"] = tag.get("name")
item["group_description"] = tag.get("description")
yield item
if include_beta:
beta = catalog.get("beta_notes", {})
for beta_group, info in beta.items():
for endpoint in info.get("confirmed_endpoints", []):
item = dict(endpoint)
item["group"] = f"beta:{beta_group}"
item["group_description"] = info.get("ga_stability")
item["beta_status"] = info.get("status")
yield item
def endpoint_match_score(template_path: str, actual_path: str) -> Optional[int]:
if template_path == actual_path:
return 1000
pattern = re.sub(r"\{[^/]+\}", "[^/]+", template_path)
pattern = "^" + pattern + "$"
if re.match(pattern, actual_path):
literal_chars = len(re.sub(r"\{[^/]+\}", "", template_path))
return 100 + literal_chars
return None
def find_endpoint(catalog: Dict[str, Any], method: str, path: str, include_beta: bool = True) -> Dict[str, Any]:
method = method.upper()
candidates: List[tuple[int, Dict[str, Any]]] = []
for endpoint in iter_endpoints(catalog, include_beta=include_beta):
if endpoint.get("method", "").upper() != method:
continue
score = endpoint_match_score(endpoint["path"], path)
if score is not None:
candidates.append((score, endpoint))
if not candidates:
raise SystemExit(f"Error: could not find endpoint metadata for {method} {path}")
candidates.sort(key=lambda item: item[0], reverse=True)
return candidates[0][1]
def parse_kv_pairs(pairs: Optional[List[str]]) -> Dict[str, str]:
result: Dict[str, str] = {}
for item in pairs or []:
if "=" not in item:
raise SystemExit(f"Error: expected KEY=VALUE, got: {item!r}")
key, value = item.split("=", 1)
result[key] = value
return result
def parse_headers(header_items: Optional[List[str]]) -> Dict[str, str]:
headers: Dict[str, str] = {}
for item in header_items or []:
if ":" not in item:
raise SystemExit(f"Error: expected 'Header: value', got: {item!r}")
name, value = item.split(":", 1)
headers[name.strip()] = value.strip()
return headers
def print_json(data: Any) -> None:
json.dump(data, sys.stdout, indent=2, ensure_ascii=False)
sys.stdout.write("\n")
def command_catalog(args: argparse.Namespace) -> int:
catalog = load_catalog()
results = []
for endpoint in iter_endpoints(catalog, include_beta=args.include_beta):
if args.group and endpoint.get("group", "").lower() != args.group.lower():
continue
if args.method and endpoint.get("method", "").upper() != args.method.upper():
continue
haystack = " ".join(
str(endpoint.get(k, "")) for k in ("group", "path", "summary", "description")
).lower()
if args.search and args.search.lower() not in haystack:
continue
if not args.include_deprecated and endpoint.get("deprecated"):
continue
results.append(
{
"group": endpoint.get("group"),
"method": endpoint.get("method"),
"path": endpoint.get("path"),
"summary": endpoint.get("summary"),
"deprecated": bool(endpoint.get("deprecated", False)),
}
)
if args.format == "json":
print_json({"count": len(results), "endpoints": results})
return 0
if not results:
print("No endpoints matched.", file=sys.stderr)
return 1
group_width = max(len(item["group"] or "") for item in results)
method_width = max(len(item["method"] or "") for item in results)
path_width = max(len(item["path"] or "") for item in results)
header = f"{'GROUP'.ljust(group_width)} {'METHOD'.ljust(method_width)} {'PATH'.ljust(path_width)} SUMMARY"
print(header)
print("-" * len(header))
for item in results:
print(
f"{(item['group'] or '').ljust(group_width)} "
f"{(item['method'] or '').ljust(method_width)} "
f"{(item['path'] or '').ljust(path_width)} "
f"{item['summary'] or ''}"
)
return 0
def command_schema(args: argparse.Namespace) -> int:
catalog = load_catalog()
endpoint = find_endpoint(catalog, args.method, args.path, include_beta=True)
result = {
"group": endpoint.get("group"),
"method": endpoint.get("method"),
"path": endpoint.get("path"),
"summary": endpoint.get("summary"),
"description": endpoint.get("description"),
"deprecated": endpoint.get("deprecated", False),
"beta_status": endpoint.get("beta_status"),
"parameters": endpoint.get("parameters"),
"request_body": endpoint.get("request_body"),
"responses": endpoint.get("responses"),
"group_description": endpoint.get("group_description"),
}
print_json(result)
return 0
def build_url(base_url: str, path: str, query: Dict[str, str]) -> str:
if not path.startswith("/"):
raise SystemExit("Error: path must start with '/'")
url = base_url.rstrip("/") + path
if query:
url += "?" + urllib.parse.urlencode(query, doseq=True)
return url
def should_retry(method: str, status: Optional[int], attempt: int, max_retries: int) -> bool:
# attempt is the current failed attempt number. Retries are allowed while attempt <= max_retries.
if attempt > max_retries:
return False
if status is None:
return True
return status in RETRYABLE_STATUSES
def parse_response_body(content_type: str, raw_bytes: bytes) -> Any:
lowered = (content_type or "").lower()
if "application/json" in lowered or lowered.endswith("+json"):
try:
return json.loads(raw_bytes.decode("utf-8"))
except Exception:
return {"_raw_text": raw_bytes.decode("utf-8", errors="replace")}
if lowered.startswith("text/") or "charset=" in lowered:
return raw_bytes.decode("utf-8", errors="replace")
return {
"_binary_base64": base64.b64encode(raw_bytes).decode("ascii"),
"_content_type": content_type,
"_byte_length": len(raw_bytes),
"_hint": "Binary response. Prefer --output FILE for attachments or other large payloads."
}
def maybe_paginate(
opener: urllib.request.OpenerDirector,
method: str,
url: str,
headers: Dict[str, str],
timeout: float,
page_limit: int,
) -> Dict[str, Any]:
pages: List[Any] = []
page_urls: List[str] = []
next_url = url
page_count = 0
while next_url and page_count < page_limit:
req = urllib.request.Request(next_url, method=method, headers=headers)
with opener.open(req, timeout=timeout) as resp:
raw = resp.read()
content_type = resp.headers.get("Content-Type", "")
body = parse_response_body(content_type, raw)
pages.append(body)
page_urls.append(next_url)
page_count += 1
next_candidate = None
if (
isinstance(body, dict)
and body.get("object") == "list"
and body.get("has_more")
and isinstance(body.get("data"), list)
and body["data"]
and isinstance(body["data"][-1], dict)
and body["data"][-1].get("id")
):
parsed = urllib.parse.urlparse(next_url)
current_query = dict(urllib.parse.parse_qsl(parsed.query))
current_query["after"] = str(body["data"][-1]["id"])
next_candidate = urllib.parse.urlunparse(
(
parsed.scheme,
parsed.netloc,
parsed.path,
parsed.params,
urllib.parse.urlencode(current_query, doseq=True),
parsed.fragment,
)
)
next_url = next_candidate
return {
"pages": pages,
"page_urls": page_urls,
"page_count": page_count,
"truncated": page_count >= page_limit and next_url is not None,
}
def command_request(args: argparse.Namespace) -> int:
method = args.method.upper()
query = parse_kv_pairs(args.query)
extra_headers = parse_headers(args.header)
base_url = os.environ.get("RESEND_BASE_URL", DEFAULT_BASE_URL)
user_agent = os.environ.get("RESEND_USER_AGENT", DEFAULT_USER_AGENT)
api_key = os.environ.get("RESEND_API_KEY")
if method not in SAFE_METHODS and args.retries > 0 and not (args.idempotency_key or args.unsafe_retries):
raise SystemExit(
"Error: refusing to auto-retry a non-safe method without --idempotency-key or --unsafe-retries"
)
if args.paginate and method != "GET":
raise SystemExit("Error: --paginate is only supported for GET requests")
body_bytes: Optional[bytes] = None
body_obj: Any = None
if args.json and args.json_file:
raise SystemExit("Error: choose either --json or --json-file, not both")
if args.json:
try:
body_obj = json.loads(args.json)
except json.JSONDecodeError as e:
raise SystemExit(f"Error: invalid --json payload: {e}")
elif args.json_file:
try:
body_obj = json.loads(Path(args.json_file).read_text(encoding="utf-8"))
except Exception as e:
raise SystemExit(f"Error: failed to read --json-file: {e}")
if body_obj is not None:
body_bytes = json.dumps(body_obj).encode("utf-8")
headers: Dict[str, str] = {
"User-Agent": user_agent,
}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
if body_bytes is not None:
headers["Content-Type"] = "application/json"
if args.idempotency_key:
headers["Idempotency-Key"] = args.idempotency_key
headers.update(extra_headers)
if args.require_auth and "Authorization" not in headers and not args.dry_run:
raise SystemExit("Error: RESEND_API_KEY is required for request mode")
url = build_url(base_url, args.path, query)
prepared = {
"method": method,
"url": url,
"headers": headers,
"json_body": body_obj,
}
if args.dry_run:
print_json({"dry_run": True, "request": prepared})
return 0
opener = urllib.request.build_opener()
attempt = 0
last_status: Optional[int] = None
while True:
attempt += 1
req = urllib.request.Request(url, data=body_bytes, method=method, headers=headers)
try:
if args.paginate:
paginated = maybe_paginate(
opener=opener,
method=method,
url=url,
headers=headers,
timeout=args.timeout,
page_limit=args.page_limit,
)
result = {
"ok": True,
"status": 200,
"request": prepared,
"pagination": paginated,
}
if args.output:
Path(args.output).write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding="utf-8")
print_json({"ok": True, "status": 200, "output_file": str(args.output)})
else:
print_json(result)
return 0
with opener.open(req, timeout=args.timeout) as resp:
status = resp.getcode()
last_status = status
raw = resp.read()
content_type = resp.headers.get("Content-Type", "")
parsed_body = parse_response_body(content_type, raw)
result = {
"ok": 200 <= status < 300,
"status": status,
"request": prepared,
"response": {
"content_type": content_type,
"body": parsed_body,
},
}
if args.include_headers:
result["response"]["headers"] = dict(resp.headers.items())
if args.output:
if isinstance(parsed_body, dict) and "_binary_base64" in parsed_body and args.binary_output:
Path(args.binary_output).write_bytes(base64.b64decode(parsed_body["_binary_base64"]))
result["response"]["saved_binary_to"] = str(args.binary_output)
Path(args.output).write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding="utf-8")
print_json({"ok": result["ok"], "status": status, "output_file": str(args.output)})
else:
print_json(result)
return 0 if result["ok"] else 1
except urllib.error.HTTPError as e:
last_status = e.code
raw = e.read()
content_type = e.headers.get("Content-Type", "")
parsed_body = parse_response_body(content_type, raw)
error_result = {
"ok": False,
"status": e.code,
"request": prepared,
"response": {
"content_type": content_type,
"body": parsed_body,
},
"attempt": attempt,
}
if args.include_headers:
error_result["response"]["headers"] = dict(e.headers.items())
if should_retry(method, e.code, attempt, args.retries):
print(
f"Retrying after HTTP {e.code} (attempt {attempt} of {args.retries + 1})...",
file=sys.stderr,
)
time.sleep(args.backoff)
continue
if args.output:
Path(args.output).write_text(json.dumps(error_result, indent=2, ensure_ascii=False), encoding="utf-8")
print_json({"ok": False, "status": e.code, "output_file": str(args.output)})
else:
print_json(error_result)
return 1
except urllib.error.URLError as e:
if should_retry(method, None, attempt, args.retries):
print(
f"Retrying after network error: {e.reason} (attempt {attempt} of {args.retries + 1})...",
file=sys.stderr,
)
time.sleep(args.backoff)
continue
print_json(
{
"ok": False,
"status": last_status,
"request": prepared,
"error": {
"type": "network_error",
"message": str(e.reason),
},
"attempt": attempt,
}
)
return 1
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Inspect the bundled Resend API catalogue or make live Resend API requests."
)
subparsers = parser.add_subparsers(dest="command", required=True)
p_catalog = subparsers.add_parser("catalog", help="List known stable endpoints")
p_catalog.add_argument("--group", help="Filter by group/tag name, e.g. Emails")
p_catalog.add_argument("--method", help="Filter by HTTP method, e.g. POST")
p_catalog.add_argument("--search", help="Free-text filter over group/path/summary")
p_catalog.add_argument("--format", choices=["json", "table"], default="table")
p_catalog.add_argument("--include-beta", action="store_true", help="Include beta/private-alpha notes")
p_catalog.add_argument("--include-deprecated", action="store_true", help="Include deprecated endpoints")
p_catalog.set_defaults(func=command_catalog)
p_schema = subparsers.add_parser("schema", help="Show schema/parameter summary for an endpoint")
p_schema.add_argument("method", help="HTTP method, e.g. POST")
p_schema.add_argument("path", help="Endpoint path, e.g. /emails or /domains/{domain_id}")
p_schema.set_defaults(func=command_schema)
p_request = subparsers.add_parser("request", help="Make a live API request")
p_request.add_argument("method", help="HTTP method, e.g. GET or POST")
p_request.add_argument("path", help="Endpoint path, e.g. /domains")
p_request.add_argument("--query", action="append", help="Query parameter as KEY=VALUE", default=[])
p_request.add_argument("--header", action="append", help="Additional header as 'Name: value'", default=[])
p_request.add_argument("--json", help="Inline JSON request body")
p_request.add_argument("--json-file", help="Path to a JSON request body")
p_request.add_argument("--idempotency-key", help="Idempotency-Key header value")
p_request.add_argument("--timeout", type=float, default=30.0, help="Request timeout in seconds (default: 30)")
p_request.add_argument("--retries", type=int, default=0, help="Retry count for 429/5xx/network errors")
p_request.add_argument("--backoff", type=float, default=1.0, help="Sleep between retries in seconds")
p_request.add_argument("--dry-run", action="store_true", help="Print the prepared request without sending it")
p_request.add_argument("--paginate", action="store_true", help="Follow cursor pagination for GET list endpoints")
p_request.add_argument("--page-limit", type=int, default=5, help="Maximum pages when --paginate is used")
p_request.add_argument("--output", help="Write the structured result JSON to a file")
p_request.add_argument(
"--binary-output",
help="When the response is binary and --output is used, also decode and save the bytes here",
)
p_request.add_argument("--include-headers", action="store_true", help="Include response headers in JSON output")
p_request.add_argument("--unsafe-retries", action="store_true", help="Allow retries for non-safe methods")
p_request.add_argument(
"--no-auth-check",
dest="require_auth",
action="store_false",
help="Allow unauthenticated requests (default is to require RESEND_API_KEY)",
)
p_request.set_defaults(func=command_request, require_auth=True)
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main())