sondahub / SendGrid sandbox
A SendGrid mock API that renders your templates and signs its webhooks
SendGrid’s v3 API, answered by sondahub: Mail Send with SendGrid’s validation and error messages, dynamic templates rendered with your data, each recipient processed, delivered, bounced, opened or clicked on a clock, suppression lists that drop later sends, and an Event Webhook signed so SendGrid’s own helpers verify it. Every send links to a preview of the email it made.
An independent imitation for testing. Not affiliated with, or endorsed by, Twilio SendGrid. No email is delivered to anyone.
Connect
- Instead of
- https://api.sendgrid.com
- Use
- https://api.sondahub.com
- API key
- any key that starts with SG. — SG.sondahub-sandbox works
- Send from
- any address @example.com
- Also at
- https://api.sondahub.com/sandbox/sendgrid/v3
- OpenAPI 3
- https://api.sondahub.com/sandbox/sendgrid/openapi.json
Import it. In Sonda: Import → From a URL, paste the OpenAPI address. You get a folder per resource and a request per operation, with example bodies and ids from the seed account, so most requests work as they are — the few that act on something you make first say so. Then set the auth: Bearer token SG.sondahub-sandbox. Any client that imports OpenAPI 3 takes the same address.
SendGrid’s SDKs know nothing about sondahub’s sessions, so give them the few lines that carry X-Sondahub-Session from one answer to the next request — otherwise every call starts from the seed account and the webhook you just made is not there.
Python (sendgrid-python)
import python_http_client
from sendgrid import SendGridAPIClient
from sendgrid.helpers.mail import Mail
sg = SendGridAPIClient('SG.sondahub-sandbox', host='https://api.sondahub.com')
# carry the sandbox session from each answer to the next request
_make = python_http_client.Client._make_request
def _keep(self, opener, request, timeout=None):
res = _make(self, opener, request, timeout)
token = res.headers.get('X-Sondahub-Session')
if token:
sg.client.request_headers['X-Sondahub-Session'] = token
return res
python_http_client.Client._make_request = _keep
r = sg.send(Mail(from_email='[email protected]', to_emails='[email protected]',
subject='Hi', html_content='<p>It works.</p>'))
print(r.status_code, r.headers['X-Message-Id'], r.headers['X-Sondahub-Preview'])
Node (@sendgrid/mail)
import client from '@sendgrid/client'
import sgMail from '@sendgrid/mail'
client.setApiKey('SG.sondahub-sandbox')
client.setDefaultRequest('baseUrl', 'https://api.sondahub.com/') // after setApiKey, which resets it
// carry the sandbox session from each answer to the next request
const request = client.request.bind(client)
client.request = async (data) => {
const [res, body] = await request(data)
const token = res.headers['x-sondahub-session']
if (token) client.setDefaultHeader('X-Sondahub-Session', token)
return [res, body]
}
sgMail.setClient(client) // and no sgMail.setApiKey: it would put the host back
const [res] = await sgMail.send({ to: '[email protected]', from: '[email protected]', subject: 'Hi', html: '<p>It works.</p>' })
Try it here
curl -X POST https://api.sondahub.com/v3/mail/send \
-H "Authorization: Bearer SG.sondahub-sandbox" \
-H "Content-Type: application/json" \
-d '{
"personalizations": [{"to":[{"email":"[email protected]","name":"Ada"}]}],
"from": {"email":"[email protected]","name":"Example Labs"},
"subject": "Hello from the sandbox",
"content": [{"type":"text/plain","value":"It works. Next step: https://example.com/next"},{"type":"text/html","value":"<p>It works. <a href=\"https://example.com/next\">Next step</a></p>"}],
"categories": ["onboarding"],
"custom_args": {"user_id":"42"}
}'
curl -X POST https://api.sondahub.com/v3/mail/send \
-H "Authorization: Bearer SG.sondahub-sandbox" \
-H "Content-Type: application/json" \
-d '{
"personalizations": [{"to":[{"email":"[email protected]"}],"dynamic_template_data":{"first_name":"Grace","company":"Example Labs","trial_days":14,"login_url":"https://app.example.com/login","tips":["Invite your team","Connect a data source"]}}],
"from": {"email":"[email protected]"},
"template_id": "d-9e3779b1d4ee281dbf79a0e837f20e1e"
}'
curl -X POST https://api.sondahub.com/v3/mail/send \
-H "Authorization: Bearer SG.sondahub-sandbox" \
-H "Content-Type: application/json" \
-d '{
"personalizations": [{"to":[{"email":"not-an-address"}]}],
"from": {"email":"[email protected]"},
"content": [{"type":"text/html","value":"<p>Hi</p>"},{"type":"text/plain","value":"Hi"}]
}'
curl -X POST https://api.sondahub.com/v3/mail/send \
-H "Authorization: Bearer SG.sondahub-sandbox" \
-H "Content-Type: application/json" \
-d '{
"personalizations": [{"to":[{"email":"[email protected]"}]}],
"from": {"email":"[email protected]"},
"subject": "Hi",
"content": [{"type":"text/plain","value":"Hi"}]
}'
curl "https://api.sondahub.com/v3/messages?query=to_email%3D%22ada%2Bclick%40example.com%22&limit=5" \ -H "Authorization: Bearer SG.sondahub-sandbox"
curl "https://api.sondahub.com/v3/suppression/bounces" \ -H "Authorization: Bearer SG.sondahub-sandbox"
From the shell, keep the token yourself: curl -i shows X-Sondahub-Session; send it back with -H "X-Sondahub-Session: …". How sessions work.
What happens to each recipient
A recipient is processed at once and delivered a second and a half later — unless a +tag in the address says otherwise, or the address is on a suppression list. The tags work on any address, so your own test inboxes can bounce on purpose.
| To | What happens |
|---|---|
| ada+bounce@… | Bounces (hard): 550 5.1.1, then the address is on the bounce list and later sends to it are dropped. |
| ada+blocked@… | Blocked (a soft bounce): the receiving server refuses it; the address goes on the blocks list. |
| ada+deferred@… | Deferred twice (451 try again later), then delivered. |
| ada+open@… | Delivered, then opened. |
| ada+click@… | Delivered, opened, then the first link is clicked. |
| ada+spamreport@… | Delivered, then reported as spam: the address goes on the spam reports list. |
| ada+unsubscribe@… | Delivered, opened, then unsubscribed — from the message’s unsubscribe group if it has one (group_unsubscribe), otherwise globally. |
| an address on a suppression list | dropped with SendGrid’s reason: Bounced Address, Spam Reporting Address, Unsubscribed Address (global, or the message’s unsubscribe group) or Invalid. mail_settings.bypass_list_management and the other bypass settings skip the lists, as at SendGrid. |
Opens need open tracking and an HTML part (the tracking pixel lives there); clicks need click tracking and a link in the HTML — or in the text, with click_tracking.enable_text. Both are on unless tracking_settings turns them off. A bounce, a spam report or an unsubscribe adds the address to its list, so the next send to it is dropped; a block goes on the blocks list, but later sends still go out, as at SendGrid. mail_settings.sandbox_mode validates and renders only: 200, nothing sent, no events — the preview link still comes back.
The Event Webhook, signed
Create up to five with POST /v3/user/webhooks/event/settings — your URL and the events you want (processed, delivered, deferred, bounce, dropped, open, click, spam_report, unsubscribe, group_unsubscribe) — and each recipient’s events are POSTed as a JSON array the moment they happen, with SendGrid’s fields: email, timestamp, event, sg_event_id, sg_message_id, smtp-id, category, your custom_args at the top level, and each event’s own (reason, status, type, url, attempt…). Retried after 1 and 3 seconds on a network error, 408, 429 or 5xx — the sandbox’s own short schedule, since it has half a minute to finish; SendGrid itself keeps retrying for hours.
Turn signing on with PATCH /v3/user/webhooks/event/settings/signed/{id} {"enabled": true}; the answer holds the public key. Then SendGrid’s own helpers verify every post:
Python
from sendgrid.helpers.eventwebhook import EventWebhook, EventWebhookHeader
# the public key: GET /v3/user/webhooks/event/settings/signed/{id}
ew = EventWebhook()
key = ew.convert_public_key_to_ecdsa(public_key)
ok = ew.verify_signature(request.get_data(as_text=True),
request.headers[EventWebhookHeader.SIGNATURE],
request.headers[EventWebhookHeader.TIMESTAMP], key)
Node
import { EventWebhook, EventWebhookHeader } from '@sendgrid/eventwebhook'
// express.raw({ type: 'application/json' }) — the signature covers the exact bytes
const ew = new EventWebhook()
const key = ew.convertPublicKeyToECDSA(publicKey)
const ok = ew.verifySignature(key, req.body, req.get(EventWebhookHeader.SIGNATURE()), req.get(EventWebhookHeader.TIMESTAMP()))
The legacy single-webhook paths (GET/PATCH /v3/user/webhooks/event/settings and …/settings/signed) work on the oldest webhook. POST /v3/user/webhooks/event/test sends two sample events to any URL. The hub-wide X-Sondahub-Webhook header on a send also gets every event, signed the same way (the webhook tester).
Dynamic templates
Send with template_id and each personalization’s dynamic_template_data, and the active version’s subject and HTML are rendered with the Handlebars SendGrid documents: {{name}} (escaped), {{{html}}}, a.b.c, {{#if}} / {{else if}} / {{else}}, {{#unless}}, {{#each}} with this, @index, @first, @last, ../, {{#with}}, {{#equals}}, {{#notEquals}}, {{#greaterThan}}, {{#lessThan}}, {{#and}}, {{#or}}, {{length}}, {{formatDate}} and {{insert name "default=…"}}. The plain-text part is made from the HTML unless the version has its own. Legacy templates put your content where <%body%> is, and substitutions work as before.
| The seed’s templates | Their data |
|---|---|
Welcomed-9e3779b1d4ee281dbf79a0e837f20e1e | first_name, company, trial_days, login_url, tips |
Password resetd-3c6ef362f70a0871ebb23143c140b829 | first_name, email, expires_in, reset_url |
Order receiptd-daa66d1378aa2fd9ef3b759b352f12ac | customer, order |
Make your own with POST /v3/templates {"name", "generation": "dynamic"} and POST /v3/templates/{id}/versions. Substitutions with a dynamic template are refused, as SendGrid refuses them.
Suppressions and unsubscribe groups
The bounce, block, spam report, invalid email and global unsubscribe lists start with a few addresses each and grow as your sends bounce, get blocked, get reported or unsubscribe. List, look up and delete them as at SendGrid. The seed account’s unsubscribe groups are 16401 Product updates and 16402 Account alerts: a send with asm.group_id drops the group’s unsubscribers, and a +unsubscribe recipient leaves that group (group_unsubscribe) rather than everything.
Email Activity and stats
GET /v3/messages?query=… filters the recipients of the seed account’s month of mail and of your session’s last 25 sends (the first 50 recipients of each) — to_email, from_email, subject, status (processed, delivered, not_delivered), msg_id, template_id, asm_group_id, last_event_time, with =, !=, LIKE "%…%", IN (…), BETWEEN TIMESTAMP "…" AND TIMESTAMP "…", Contains(categories, "…"), AND, OR, NOT and parentheses. GET /v3/messages/{msg_id} lists one recipient’s events. GET /v3/stats?start_date=… counts requests, deliveries, bounces, opens, clicks and the rest by day, week or month.
Simulate incoming mail (Inbound Parse)
SendGrid posts your app a multipart/form-data request when mail arrives at your parse hostname: headers, dkim, to, from, subject, text, html, envelope, charsets, SPF, sender_ip, attachments — or the whole MIME message as email with send_raw. This sends your webhook exactly that and shows what it answered.
{"url", "to", "from", "subject", "text", "html", "send_raw", "spam_check"} as JSON. Leave url out and send your session token, and your parse setting for the To domain decides where it goes.What it answers
Errors are SendGrid’s: {"errors": [{"field", "message"}]} — Mail Send lists every problem at once, with SendGrid’s messages and a help link. The seed account has 60 contacts on 3 lists, 40 messages from the month before, two verified senders and the authenticated domain example.com. Contacts upserts and deletes answer with a job: upserted contacts appear two seconds later, deletes take effect at once.
Questions
Is this SendGrid?
No — an independent imitation of SendGrid’s v3 API for testing, not affiliated with or endorsed by Twilio SendGrid. No email is delivered to anyone: each send answers with a link to a preview of what it would have sent.
Will my SendGrid SDK work against it?
Yes, by changing the host — sendgrid-python’s host argument, @sendgrid/client’s baseUrl. The Python set-up on this page was run against the sandbox with the official sendgrid-python SDK, and the Event Webhook posts passed both sendgrid-python’s verify_signature and @sendgrid/eventwebhook’s verifySignature; the Node set-up follows @sendgrid/client’s own request and header options. Add the session hook, or each call starts from the seed account.
Why do I get a 403 about a verified Sender Identity?
Because SendGrid refuses a from address that is not on an authenticated domain or a verified single sender, and so does the sandbox — it is the error integrations meet first. The seed account has authenticated example.com (any address there works) and verified [email protected] and [email protected]. Add your own domain and validate it, or add a single sender: it verifies itself five seconds later, since there is no inbox here to click the link in.
How do I see the email I sent?
Every accepted send — the 202, and sandbox_mode’s 200 — carries X-Sondahub-Preview: a link to a sondahub page with the subject and body as each of the first five personalizations would get them — your dynamic template rendered with your dynamic_template_data, the plain-text part, the attachments’ names, and anything the template could not read. The message travels inside the link, so the page works for anyone you send it to.
How are the webhooks signed?
As SendGrid signs them when you turn signing on: ECDSA with P-256 and SHA-256 over the X-Twilio-Email-Event-Webhook-Timestamp header followed by the raw body, base64 in X-Twilio-Email-Event-Webhook-Signature. The public key comes from GET /v3/user/webhooks/event/settings/signed/{id}. The sandbox signs with one published key pair — it proves your verification code, it protects nothing.
What is not modelled?
Marketing Single Sends, Automations, segments and designs, subusers, IP pools and warmup, teammates, API key management, link branding, reverse DNS and Email Address Validation: those /v3 paths answer SendGrid’s error shape saying so, and a send naming an ip_pool_name is refused (there are no pools). A scheduled send (send_at in the future) sends no webhooks — the sandbox can only keep working for half a minute after it answers — but its events show in Email Activity when they happen, and pausing or cancelling its batch holds or drops it.