Skip to main content
Stateful SendGrid v3 emulation: Mail Send with SendGrid’s validation and error envelope, dynamic templates rendered with Handlebars, API keys with scopes, Marketing contacts and lists with SGQL search, the Event Webhook with ECDSA signatures, suppressions, and an inbox. Nothing is ever delivered to a real mailbox. The tests drive it with the official @sendgrid/mail and @sendgrid/client (8.1.6), and verify webhook signatures with @sendgrid/eventwebhook (8.0.0).

At a glance

Start

SendGrid starts only when named in config or with --service. When it is the only enabled service, it listens on http://localhost:4000. Point @sendgrid/client and @sendgrid/mail at the emulator with the client’s baseUrl. setApiKey resets the client’s base URL, so set the base URL after it:

URL mapping

Auth

Every /v3 request needs Authorization: Bearer <key>. Keys seeded under api_keys or created with POST /v3/api_keys carry their scopes, and a route answers 403 access forbidden when the key lacks the scope it needs: Mail Send needs mail.send, templates need templates.*, Marketing needs marketing.read, and webhook settings need user.webhooks.event.settings.*. Any other token the CLI accepts acts as a Full Access key. A missing header is 401 authorization required; an unknown key is 401.

Errors

Errors use SendGrid’s { errors: [{ message, field, help }] } body with its messages, for example The from object must be provided for every email send. with field from. Unknown /v3 paths answer 404.

Mail Send

POST /v3/mail/send takes:
  • personalizations (1 to 1000), each with to, cc, bcc, subject, headers, substitutions, dynamic_template_data, custom_args, and send_at
  • from, reply_to or reply_to_list, subject, and content (text/plain first, then text/html)
  • attachments in base64; disposition: inline needs a content_id
  • template_id, categories (up to 10), custom_args, headers (reserved headers are refused), send_at, batch_id, asm, and ip_pool_name
  • mail_settings.sandbox_mode and mail_settings.footer
Accepted mail answers 202 with an empty body and an X-Message-Id header. Sandbox mode validates the request and answers 200 without sending. A body over 30 MB answers 413. Each personalization becomes one message in the inbox. A dynamic template (d-...) renders its active version’s subject, HTML, and text with each personalization’s dynamic_template_data. A legacy template fills <%subject%> and <%body%>. The Handlebars subset matches what SendGrid supports:
  • {{name}} (HTML escaped) and {{{html}}}
  • {{#if}}, {{else}}, {{else if}}, {{#unless}}, and {{#with}}
  • {{#each}} with this, @index, @key, @first, @last, ../, and @root
  • {{#equals a b}}, {{#notEquals}}, {{#greaterThan}}, {{#lessThan}}, {{#and}}, and {{#or}}
  • {{insert x "default=y"}}, {{formatDate d "YYYY-MM-DD"}}, and {{length list}}
  • comments and ~ whitespace control

Scheduling

send_at (unix seconds, up to 72 hours ahead) holds mail until then, and it goes out on its own timer. POST /v3/mail/batch creates a batch_id. POST /v3/user/scheduled_sends with { batch_id, status: "pause" | "cancel" } holds or discards the batch, and PATCH and DELETE /v3/user/scheduled_sends/{batch_id} change or lift it. Paused mail more than 72 hours past its send time is discarded.

Templates

/v3/templates creates, lists, gets, renames, and deletes templates (generation: legacy | dynamic). Listing needs page_size (1 to 200), filters with generations, and pages with page_token. POST /v3/templates/{id} duplicates a template. /v3/templates/{id}/versions creates, gets, updates, and deletes versions, and POST /v3/templates/{id}/versions/{version_id}/activate makes one active. Versions with Handlebars errors carry warnings.

API keys

GET /v3/scopes returns the caller’s scopes. /v3/api_keys creates a key with name and optional scopes (Full Access when left out), lists, gets, renames with PATCH, replaces name and scopes with PUT, and deletes. A key cannot grant scopes it lacks, and billing scopes cannot be mixed with others.

Marketing

Contacts

  • PUT /v3/marketing/contacts — Upsert contacts: takes { list_ids?, contacts: [{ email, first_name, ..., custom_fields }] }, upserts by email, and returns { job_id }; read the job at GET /v3/marketing/contacts/imports/{job_id}
  • GET /v3/marketing/contacts — List contacts
  • GET /v3/marketing/contacts/{id} — Get contact
  • DELETE /v3/marketing/contacts?ids= or ?delete_all_contacts=true — Delete contacts
  • GET /v3/marketing/contacts/count — Count contacts
  • POST /v3/marketing/contacts/batch — Get contacts by id: with { ids }
  • POST /v3/marketing/contacts/search/emails — Search contacts by email: with { emails }
  • POST /v3/marketing/contacts/search — Search contacts: with an SGQL query, such as email LIKE 'ada%' AND CONTAINS(list_ids, '<list id>')

Lists

  • /v3/marketing/lists: create, list, get (contact_sample=true), rename, and delete (delete_contacts=true)
  • DELETE /v3/marketing/lists/{id}/contacts?contact_ids= — Remove contacts from a list
  • GET /v3/marketing/lists/{id}/contacts/count — Count list contacts

Suppressions

Bounces, spam reports, and unsubscribes from the simulate routes or the seed join their suppression list. Later mail to the address is dropped with reason Bounced Address, Spam Reporting Address, or Unsubscribed Address.
  • GET and DELETE /v3/suppression/bounces ({ delete_all: true } or { emails }), and GET and DELETE /v3/suppression/bounces/{email}
  • GET /v3/suppression/spam_reports and DELETE /v3/suppression/spam_reports/{email} — Spam reports: list them, or delete one by email
  • GET /v3/suppression/unsubscribes — List unsubscribes

Event Webhook

POST /v3/user/webhooks/event/settings creates a webhook (up to five) with url, enabled, a boolean per event (processed, delivered, open, click, bounce, dropped, deferred, spam_report, unsubscribe, group_unsubscribe, group_resubscribe), friendly_name, and an optional OAuth client (oauth_client_id, oauth_client_secret, oauth_token_url). GET /v3/user/webhooks/event/settings/all lists them, and GET, PATCH, and DELETE /v3/user/webhooks/event/settings/{id} manage one. The older GET and PATCH /v3/user/webhooks/event/settings act on the first webhook. PATCH /v3/user/webhooks/event/settings/signed/{id} with { enabled: true } turns on signing and returns { id, public_key }. Events go out as one JSON array per request (a send posts processed and delivered for every recipient together), signed with ECDSA P-256 over timestamp + body in X-Twilio-Email-Event-Webhook-Signature and X-Twilio-Email-Event-Webhook-Timestamp:
With an OAuth client, the emulator fetches a client credentials token first and sends it as Authorization: Bearer <token>. Failed posts are retried three times. POST /v3/user/webhooks/event/test with { id?, url } sends a sample batch, signed with that webhook’s key when it has one.

Simulate

POST /_sendgrid/simulate/{event} with { email, message_id?, url?, reason?, useragent? } makes a recipient of sent mail do what a real inbox does. Events: delivered, open, click, bounce, blocked, deferred, dropped, spamreport, unsubscribe, group_unsubscribe, and group_resubscribe (group events need mail sent with asm.group_id). It updates the recipient, posts the event to the webhooks, and returns it. Without message_id, the latest mail to the address is used.

Seed config

  • id: generated when left out
Keys default to Full Access, and a key’s middle part becomes its id. Templates are dynamic unless generation: legacy; the last version with active: true (or the first) is active. Webhook events default to all; read a signed webhook’s public key from GET /v3/user/webhooks/event/settings/signed. Seeding again does not duplicate entries.

Inbox and inspector

GET /inbox lists sent mail, and /inbox/{message_id} previews it. GET /_inspector has tabs for messages, templates, contacts and lists, webhooks with recent deliveries, suppressions, and API keys.

Limits

  • No SMTP relay, Single Sends, Automations, segments, custom field definitions, subusers, IP pools, or stats.
  • Open and click tracking rewrite nothing; use the simulate routes for engagement events.
  • Webhook retries run three times over about 20 seconds, where SendGrid retries for 24 hours.

Conformance

See CONFORMANCE.md for every provider.