ReferenceWebhooks

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.

ProviderSigning methodKey header(s)
StripeHMAC-SHA256 computed over the raw request bodyStripe-Signature
TwilioHMAC-SHA1 computed over the full public URL and its POST parametersX-Twilio-Signature
ResendSvix (an open standard called standardwebhooks) HMAC over the raw bodysvix-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

StatusBodyMeaning
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:

body
MessageSid

Twilio's unique identifier for the message (e.g. SMxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx).

body
From

The sender's phone number in E.164 format (e.g. +15558675309).

body
To

The Zoop-managed phone number that received the message. Used to look up which tenant owns this number.

body
Body

The SMS message text. Defaults to an empty string if absent.

What happens after verification:

  1. The To number is looked up against tenant_inbound_addresses. If no tenant owns that number, the message is quarantined — stored for operator review — and Twilio receives a 200.
  2. If a tenant is found, Zoop tries to match the From number to an existing customer and contact.
  3. The message is written to the communications table with channel: 'sms', direction: 'inbound', status: 'received'.
  4. A duplicate MessageSid (Twilio re-delivery) is acknowledged with 200 — 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:

body
MessageSid

The same SID Zoop received when the message was sent.

body
MessageStatus

The new status. Zoop maps the following Twilio values:

Twilio statusZoop maps to
accepted, scheduled, queuedqueued
sending, sentsending
delivereddelivered
undelivered, failedfailed
read, receiving, receivedno change
body
ErrorMessage

Human-readable failure reason. Stored on the row when status becomes failed.

body
ErrorCode

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:

header
svix-id

Unique delivery ID. Used by Svix for replay protection.

header
svix-timestamp

Unix timestamp of the delivery. Used in the HMAC calculation.

header
svix-signature

The HMAC signature string. Format: v1,<base64-encoded-hmac>.

Payload fields:

body
from

Sender's email address.

body
to

The Zoop-managed email address that received the message. Used to look up the tenant.

body
messageId

Resend's unique message ID.

body
subject

Email subject line.

body
text

Plain-text body of the email.

body
html

HTML body of the email.

body
inReplyTo

In-Reply-To header value, used for threading the message into an existing conversation.

body
dmarc

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.

body
attachments

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:

  1. The to address is resolved to a tenant. Unmatched addresses are quarantined — stored for operator review — and Resend receives 200.
  2. The communications row is inserted before any attachment uploads. A duplicate messageId (Svix re-delivery) is caught by the unique index, returns 200, and skips re-uploading.
  3. Attachments are uploaded to Supabase Storage (communications-attachments bucket) and their paths are written back to the row. If an upload fails, the text body is still saved.
  4. Svix retries on 5xx. When the signature is valid but the payload schema is unrecognized (for example, after a provider change), Zoop returns 200 with accepted: 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:

body
type

The event type. One of:

EventZoop maps to
email.sentsending
email.delivereddelivered
email.openeddelivered (also sets read_at)
email.clickeddelivered (also sets read_at)
email.bouncedfailed
email.complainedfailed
email.failedfailed
email.delivery_delayedno change (informational)
body
data.email_id

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.

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_KEYsk_test_ or rk_test_ means test; sk_live_ or rk_live_ means live — and then reads the matching env var:

DestinationModeEnv var
ConnecttestSTRIPE_WEBHOOK_SECRET_CONNECT_TEST
ConnectliveSTRIPE_WEBHOOK_SECRET_CONNECT_LIVE
PlatformtestSTRIPE_WEBHOOK_SECRET_PLATFORM_TEST
PlatformliveSTRIPE_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.