Plain-text body of the email.
Webhooks
Inbound webhooks Zoop receives from Stripe, Twilio, and Resend — endpoints, verification, and payload shapes.
A webhook is an HTTP request a service sends to your server when something happens — a payment lands, an SMS arrives, an email bounces. This page covers the webhooks that Stripe, Twilio, and Resend send to Zoop, not webhooks that Zoop sends to you.
Zoop receives webhooks from three providers: Stripe (payments), Twilio (SMS), and Resend (email). These endpoints are public — no Authorization header is required — but every request is rejected unless the provider's signature is valid. You do not call these endpoints yourself; the providers do.
Zoop does not send outbound webhooks to URLs you register. There is no subscription mechanism. If you need to react to events in Zoop, poll the API instead.
How signature verification works
Each provider signs its requests so Zoop can confirm they are genuine. The signing method differs per provider.
| Provider | Signing method | Key header(s) |
|---|---|---|
| Stripe | HMAC-SHA256 computed over the raw request body | Stripe-Signature |
| Twilio | HMAC-SHA1 computed over the full public URL and its POST parameters | X-Twilio-Signature |
| Resend | Svix (an open standard called standardwebhooks) HMAC over the raw body | svix-id, svix-timestamp, svix-signature |
A request that fails signature verification is rejected with a 400 or 401 response. The provider will retry — Zoop does not silently accept unverified requests.
Stripe
Zoop registers two separate Stripe webhook endpoints. Each has its own signing secret and listens to a different scope of events.
Stripe platform endpoint
POST /api/webhooks/stripe/platform
Receives events on the Your account scope — things that happen on the Zoop platform account itself (platform-level charges, indirect destination charges, and Stripe Connect v2 account state changes).
Stripe connect endpoint
POST /api/webhooks/stripe/connect
Receives events on the Connected accounts scope — things that happen on your Stripe connected account (charges, refunds, disputes, payouts, and related objects).
Keep the raw body intact
Stripe computes its signature against the exact raw bytes it sent. If anything re-serializes the body before verification — for example, parsing JSON and then re-stringifying it — the signature will not match and the request will be rejected. Always read the body as a raw string; never call request.json() before verifying.
Verification flow
Both Stripe routes follow the same pattern:
Read the raw body
The route reads the request body as a plain string using request.text() — not request.json().
Verify the Stripe-Signature header
The signing secret for the endpoint is read from an environment variable. If the signature does not match, the route returns 400 { "error": "Invalid signature" } and Stripe retries.
Check live/test mode
The event's livemode field is compared against the deployment's expected mode (derived from the STRIPE_SECRET_KEY prefix — sk_test_ means test, sk_live_ means live). A mismatch parks the event and returns 200 — Zoop does not ask Stripe to retry, because a mode mismatch is a config problem, not a temporary error.
Store the event
Verified events are written to the domain_event_outbox table in a single database transaction. If the same Stripe event ID arrives twice (Stripe re-delivery), the route responds 200 { "received": true, "deduped": true } and skips the write.
Stripe success responses
// Normal ingest
{ "received": true, "ingested": 1 }
// Re-delivered event (already ingested)
{ "received": true, "deduped": true }
// Event parked due to live/test mode mismatch
{ "received": true, "parked": true, "reason": "mode_mismatch" }
// No parseable event ID — acknowledged, not ingested
{ "received": true, "ingested": 0 }
Stripe error responses
| Status | Body | Meaning |
|---|---|---|
400 | { "error": "Invalid signature" } | Signature check failed. Stripe will retry. |
500 | { "error": "ingest_failed" } | Database write failed. Stripe will retry. |
500 | { "error": "misconfigured" } | Signing secret env var is missing. Stripe will retry while you fix config. |
Twilio
Zoop registers two Twilio webhook callbacks: one for messages your customers send in, and one for delivery status updates on messages Zoop sends out.
Inbound SMS
POST /api/webhooks/twilio/inbound-sms
Called by Twilio when a customer sends an SMS to a Zoop-managed phone number. The request body is application/x-www-form-urlencoded.
Required fields Twilio sends:
Twilio's unique identifier for the message (e.g. SMxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx).
The sender's phone number in E.164 format (e.g. +15558675309).
The Zoop-managed phone number that received the message. Used to look up which tenant owns this number.
The SMS message text. Defaults to an empty string if absent.
What happens after verification:
- The
Tonumber is looked up againsttenant_inbound_addresses. If no tenant owns that number, the message is quarantined — stored for operator review — and Twilio receives a200. - If a tenant is found, Zoop tries to match the
Fromnumber to an existing customer and contact. - The message is written to the
communicationstable withchannel: 'sms',direction: 'inbound',status: 'received'. - A duplicate
MessageSid(Twilio re-delivery) is acknowledged with200— a unique index on(tenant_id, provider, provider_message_id)prevents double-writes.
Response: Always text/xml TwiML — <Response></Response> (empty, no reply). Zoop responds 200 on success and 401 on signature failure. A 5xx response causes Twilio to retry.
SMS status callback
POST /api/webhooks/twilio/sms-status
Called by Twilio when the delivery status of an outbound SMS changes. The request body is application/x-www-form-urlencoded.
Required fields Twilio sends:
The same SID Zoop received when the message was sent.
The new status. Zoop maps the following Twilio values:
| Twilio status | Zoop maps to |
|---|---|
accepted, scheduled, queued | queued |
sending, sent | sending |
delivered | delivered |
undelivered, failed | failed |
read, receiving, received | no change |
Human-readable failure reason. Stored on the row when status becomes failed.
Twilio error code. Stored on invoice delivery rows when status becomes failed.
What happens after verification:
Zoop looks up the MessageSid in the communications table first, then falls back to invoice_deliveries. Status updates only move forward — a queued callback that arrives after delivered is ignored. The route always responds 200 so Twilio does not retry stale callbacks.
How Twilio signature verification works
Twilio signs the exact public URL it posted to, plus all the form parameters in the request body. On Vercel, the internal hostname can differ from the public hostname, so Zoop rebuilds the correct public URL from trusted proxy headers (x-forwarded-*) before running the signature check.
Resend
Resend uses Svix — an open standard called standardwebhooks — to deliver and sign webhook payloads. Zoop registers two Resend endpoints.
Resend inbound email
POST /api/webhooks/resend/inbound
Called by Resend when a customer sends an email to a Zoop-managed address. The request body is JSON, signed with the RESEND_WEBHOOK_SECRET env var.
Svix headers required:
Unique delivery ID. Used by Svix for replay protection.
Unix timestamp of the delivery. Used in the HMAC calculation.
The HMAC signature string. Format: v1,<base64-encoded-hmac>.
Payload fields:
Sender's email address.
The Zoop-managed email address that received the message. Used to look up the tenant.
Resend's unique message ID.
Email subject line.
HTML body of the email.
In-Reply-To header value, used for threading the message into an existing conversation.
DMARC (email authentication) verdict from Resend's gateway. One of pass, fail, neutral, or none. When fail, the sender's identity cannot be trusted, so Zoop stores the message but does not link it to a customer.
Array of attachment objects. Each has filename (string), content (base64 string), and contentType (string).
Zoop enforces these limits before storing:
- Per-attachment max: 10 MB
- Total per-message max: 25 MB
- Max attachment count: 20
- Allowed MIME types:
image/jpeg,image/png,image/gif,image/webp,application/pdf,text/plain, and common Microsoft Office and OpenDocument formats. HTML and other types are rejected.
What happens after verification:
- The
toaddress is resolved to a tenant. Unmatched addresses are quarantined — stored for operator review — and Resend receives200. - The
communicationsrow is inserted before any attachment uploads. A duplicatemessageId(Svix re-delivery) is caught by the unique index, returns200, and skips re-uploading. - Attachments are uploaded to Supabase Storage (
communications-attachmentsbucket) and their paths are written back to the row. If an upload fails, the text body is still saved. - Svix retries on
5xx. When the signature is valid but the payload schema is unrecognized (for example, after a provider change), Zoop returns200withaccepted: false— retrying the same payload would not help.
Resend email events
POST /api/webhooks/resend/email-events
Called by Resend when the delivery status of an outbound email changes. Signed the same way as the inbound endpoint (Svix, same RESEND_WEBHOOK_SECRET).
Payload fields:
The event type. One of:
| Event | Zoop maps to |
|---|---|
email.sent | sending |
email.delivered | delivered |
email.opened | delivered (also sets read_at) |
email.clicked | delivered (also sets read_at) |
email.bounced | failed |
email.complained | failed |
email.failed | failed |
email.delivery_delayed | no change (informational) |
Resend's message ID. Matched against provider_message_id in communications and provider_id in invoice_deliveries.
What happens after verification:
Zoop looks up the email_id in communications first, then falls back to invoice_deliveries. The route always responds 200 — even when the database update fails — to prevent Svix from queuing up to 24 hours of retries for an error that retrying would not fix.
Testing locally
Each provider has a way to forward real webhook events to your local dev server. Pick the tab for the provider you want to test.
Install the Stripe CLI, then run:
stripe listen --forward-to localhost:3000/api/webhooks/stripe/connect
When it starts, the Stripe CLI prints a signing secret that looks like whsec_.... Copy it and add it to your .env.local as STRIPE_WEBHOOK_SECRET_CONNECT_TEST.
To test the platform endpoint instead, forward to /api/webhooks/stripe/platform and set STRIPE_WEBHOOK_SECRET_PLATFORM_TEST.
Twilio cannot reach localhost directly, so you need a tunnel that gives your machine a public URL. ngrok is a common choice.
# 1. Start the tunnel — ngrok gives you a public URL like https://abc123.ngrok-free.app
ngrok http 3000
# 2. In the Twilio console, set your phone number's inbound webhook URL to:
# https://<your-ngrok-subdomain>.ngrok-free.app/api/webhooks/twilio/inbound-sms
Twilio signs requests with your TWILIO_AUTH_TOKEN. Make sure your .env.local has the same auth token as the Twilio account sending the test request.
Resend also cannot reach localhost, so start a tunnel first.
# 1. Start the tunnel
ngrok http 3000
# 2. In the Resend dashboard, go to Webhooks and add a new endpoint.
# Use your ngrok URL: https://<your-ngrok-subdomain>.ngrok-free.app/api/webhooks/resend/inbound
#
# 3. Resend shows a signing secret (whsec_...) after you save the endpoint.
# Add it to your .env.local as RESEND_WEBHOOK_SECRET.
Stripe signing secret env vars
Stripe issues a different signing secret for each endpoint and each mode (test vs. live). Zoop figures out the mode from the prefix of your STRIPE_SECRET_KEY — sk_test_ or rk_test_ means test; sk_live_ or rk_live_ means live — and then reads the matching env var:
| Destination | Mode | Env var |
|---|---|---|
| Connect | test | STRIPE_WEBHOOK_SECRET_CONNECT_TEST |
| Connect | live | STRIPE_WEBHOOK_SECRET_CONNECT_LIVE |
| Platform | test | STRIPE_WEBHOOK_SECRET_PLATFORM_TEST |
| Platform | live | STRIPE_WEBHOOK_SECRET_PLATFORM_LIVE |
For the Connect endpoint only, Zoop falls back to the older names STRIPE_WEBHOOK_SECRET_TEST / STRIPE_WEBHOOK_SECRET_LIVE, and then to the legacy single-secret STRIPE_WEBHOOK_SECRET, if the per-mode var is missing. The Platform endpoint has no fallback — you must set its per-mode var explicitly. If the resolved secret is missing, the route returns 500 { "error": "misconfigured" } and Stripe retries while you fix the config.