Send webhooks
Available — including the application/subscription model & the consumer portal.The Send side: emit your own events as webhooks-as-a-service. You don't run delivery infrastructure — Emithook signs, retries, circuit-breaks, logs, and archives every send through the same engine the relay uses. Only the ingress differs.
Three ways to resolve a destination
All three share signing, retry/backoff, the per-destination circuit breaker, SSRF protection, DLQ, and logs. They differ only in how the target is chosen:
| Pattern | Use it when | How it resolves |
|---|---|---|
| Direct send | One-off / system-to-system push, no event mapping. | POST /v1/send names a registered destination id or a validated HTTPS URL. |
| Static fan-out | An event always goes to the same place(s). | An event type → a fixed route set. |
| Per-tenant (applications) | A multi-tenant product where each customer wants their own URL/queue for the same event. | app_id = your end-customer's id; POST /v1/app/{id}/event resolves that tenant's endpoints. |
Direct send
The simplest path — one call delivers one payload:
curl -X POST https://api.emithook.com/v1/send \
-H "Authorization: Bearer $EK_KEY" \
-H "Idempotency-Key: inv_001" \
-H "Content-Type: application/json" \
-d '{
"destination": "dst_01JX9...",
"event_type": "invoice.created",
"payload": { "id": "INV-2026-001", "amount": 4999, "currency": "INR" }
}'// → 202 Accepted
{ "message_id": "msg_01JX9..." }Send Idempotency-Key so client retries can't double-deliver. See the Quickstart for the end-to-end version.
Queue and broker destinations
A destination doesn't have to be an HTTPS URL — it can be a message broker. The same engine (signing context, retry/backoff, per-destination circuit breaker, DLQ, logs) applies; only the final hop changes: instead of an HTTP POST, the event is published to the broker. One adapter class per broker implements the same QueuePort (publish and consume), so a broker can be both a delivery target and an ingestion source.
Every adapter passes the same shared QueuePort conformance suite (publish/receive, ack removes, nack redelivers with an incrementing delivery count, FIFO within a group, consume() auto-ack, health):
type | Broker | FIFO within a group | Delivery count | Conformance |
|---|---|---|---|---|
sqs | AWS SQS | FIFO queues (MessageGroupId) | ApproximateReceiveCount | CI (ElasticMQ) |
nats | NATS JetStream | stream order | JetStream redelivery count | CI |
redis | Redis Streams | stream order | XCLAIM delivery count | CI |
amqp | RabbitMQ | queue order | quorum-queue x-delivery-count | CI (RabbitMQ) |
kafka | Apache Kafka | per-key partition order | adapter-tracked per offset | CI (Redpanda) |
pubsub | Google Pub/Sub | message ordering (orderingKey) | deliveryAttempt (via DLQ policy) | CI (emulator) |
azure | Azure Service Bus | queue order | deliveryCount | conformance-ready — not CI-verified |
Azure Service Bus
The Azure adapter is implemented against the same contract and ships with the identical conformance suite, but there is no lightweight Service Bus emulator that supports dynamic queue creation, so the suite is gated on AZURE_SERVICEBUS_TEST_URL and skipped in CI. Point that variable at a real namespace to run the full contract end to end.
Broker connection details live in the destination's stored configuration, not in environment variables — only the self-host ingest/delivery backbone (QueuePort) is env-configured. See self-hosting.
The application / subscription model Available
Turn Emithook into "webhooks for your product." Register each of your end-customers as an Application (keyed by your own id), each holding endpoints (URL/queue + event-type filter + its own signing secret). Then fan out:
curl -X POST https://api.emithook.com/v1/app/cust_8f2a/event \
-H "Authorization: Bearer $EK_KEY" \
-d '{ "event_type": "invoice.created", "payload": { "id": "INV-1" } }'// → 202 Accepted — fanned out to every matching endpoint
{ "message_id": "msg_01JX9...", "fanout": 3 }Applications can be lazily auto-created on first send. The data model (Environment → Application → Endpoint, plus Message/Attempt/EventType) is defined in the glossary.
Consumer portal Available
Your end-customers manage their own endpoints, view delivery logs, replay failures, browse the event catalog, and rotate their secret — in an embeddable, white-label portal scoped to one application via a magic link, with no Emithook account. This is what offloads webhook support from your team to theirs.
Delivery envelope
Every delivery, to any destination (HTTPS, SQS, or another queue), carries the whole original request, not just its body. The body arrives already parsed, so a consumer of a shared destination can tell who sent an event and act on it without parsing anything:
{
"version": 2,
"metadata": {
"event_id": "evt_01JX9…",
"identifier": "ep_01JX9…",
"name": "Stripe prod",
"source": "https",
"url": "https://wh.emithook.com/acme/stripe?mode=live",
"method": "POST",
"path": "/acme/stripe",
"content_type": "application/json",
"body_encoding": "json",
"size": 1834,
"source_ip": "54.187.174.169",
"verification": { "status": "verified", "scheme": "stripe" },
"transformed": false,
"captured_at": "2026-09-18T10:22:31.412Z",
"posted_at": "2026-09-18T10:22:31.689Z",
"expanded_json_paths": []
},
"data": {
"body": { "id": "evt_123", "amount": 4999 },
"headers": { "content-type": "application/json", "stripe-signature": "t=…,v1=…" },
"queries": { "mode": "live" }
}
}versionis the envelope version. Switch on it if you ever need to handle more than one.metadata.identifier/nametell you which endpoint received the event: its stable id, and its name (or its URL path if it has no name). For an email with no endpoint behind it, they are the alias id and address. Route onidentifierwhen many webhooks feed one destination, because it stays the same if you rename the endpoint or move it to another domain.metadata.event_idequals thewebhook-idheader. Use it to drop duplicate retries.metadata.verificationsays whether Emithook checked the provider's signature on the way in (verified/failed/unverified) and whichschemeit used. Both are recorded at the same moment, so a later change to the endpoint's settings can't make them disagree.data.bodyis decoded for you.metadata.body_encodingsays how:body_encodingInbound body data.bodyjsona JSON media type, or a JSON object/array sent with no type or as text/plainthe parsed value formapplication/x-www-form-urlencoded(UTF-8)an object (a repeated key becomes an array) textanything else, including the fallbacks below the text, as-is base64a binary type ( application/octet-stream,image/*,audio/*,video/*, PDF, zip, protobuf), or bytes that aren't valid UTF-8the bytes, base64-encoded Decoding never loses data. When parsing would change the content, the body is delivered as
textinstead:- JSON containing an integer larger than 253, such as a 64-bit id, which a JavaScript number would round.
- JSON containing a number with more than 17 significant digits, or one too large to represent (
1e400). - A form in a non-UTF-8 charset, such as PayPal IPN's
windows-1252.
Always check
body_encodingrather than assuming a provider's type.JSON inside a string is expanded too. Some providers put JSON inside a string field: Shiprocket's
charge_info, or Slack's interactivepayload=form field. When ajsonorformbody has a string value that holds a JSON object or array,data.bodycarries it parsed, andmetadata.expanded_json_pathslists where, as JSON Pointers (["/charge_info"];[]when nothing was expanded).- It follows JSON found inside an already-expanded value, up to 3 expansions along one path.
- Only objects and arrays are expanded.
"42","true", ids, a string holding a JSON string (double-encoded), and a body that is itself just a string all stay as they are. - A string stays a string if it doesn't parse, or if a number in it wouldn't survive: an integer beyond 253, more than 17 significant digits, or an overflow such as
1e400. - The values are preserved but not the exact text: whitespace,
1.10vs1.1,\u00e9vsé, and a duplicated key (only the last is kept) can differ. So if you check a signature over such a string, for example an AWS SNSMessagerelayed through Emithook, rebuild it from the original request (the request log, or Mirror for HTTPS destinations) rather than fromdata.body. - Bounded per delivery: at most 100 values are expanded (the rest stay strings), after at most 1,000 parse attempts over 1 MB of text.
- A transform's output is never expanded: it arrives exactly as your transform shaped it.
data.headersare the inbound headers, minus secrets Emithook won't pass on (authorization,cookie,set-cookie,proxy-authorization).data.queriesis the parsed query string, one value per key ({}when there is none). If a key is repeated, it keeps its last value;metadata.urlhas the full query string.metadata.content_typeandsize(in bytes) describe the inbound request.metadata.captured_atis when Emithook received the request.metadata.posted_atis when Emithook posted this delivery to your destination: each retry and each replay carries its own, soposted_at − captured_atis how long the event took to reach you. On HTTPS deliveries it is the same instant as thewebhook-timestampheader.
Consuming these from a queue? Deliver to Amazon SQS → Consume the messages has example messages for each body type, TypeScript types, a NestJS DTO, and Node.js and Python consumers.
Every key is always present, set to null when it doesn't apply (for example url for an email), so a consumer can rely on one fixed shape. data holds values the source supplied, so treat it as untrusted input, exactly as you would the raw body.
Verify Emithook's signature, not the provider's
Because data.body is parsed, you can't re-check the provider's own signature (e.g. stripe-signature) against it. Configure inbound verification on the endpoint so Emithook checks the provider's signature on arrival and reports the result in metadata.verification. Then check Emithook's webhook-signature on the delivery. If you need the provider's exact bytes, use Mirror, which forwards the request unchanged.
With the TypeScript SDK, receiving is one call:
import { verifyDelivery } from '@emithook/sdk'
const event = await verifyDelivery<{ amount: number }>({
body: rawBody, // the raw request body, exactly as received
headers: req.headers,
secret: process.env.EMITHOOK_SIGNING_SECRET!, // the destination's whsec_…
})
event.metadata.name // "Stripe prod"
event.data.body.amount // 4999, already parsedTriggering another API instead
The envelope is what a consumer built against Emithook wants. When the receiver is a third-party API with its own contract — a Slack incoming webhook, an internal service, a partner endpoint — set the destination's payload to raw and the delivered body is the event body alone: no wrapper, no original key.
envelope (default) | raw | |
|---|---|---|
| Body | {version, metadata, data} | the event body, verbatim |
| With a transform | transform output in data.body, request under original | transform output only |
| Without one | the original request, wrapped | a pure relay of the inbound body |
webhook-* headers | always | only when a signing secret is set |
An https destination also takes a method (POST/PUT/PATCH), a timeoutMs (1–30 s, default 15 s), and custom headers sent with every delivery:
{
"payload": "raw",
"method": "POST",
"headers": { "Authorization": "Bearer ${secret.slack_token}" }
}A header value can reference a stored secret as ${secret.NAME}, resolved at delivery — so the credential lives in the secret store, not in your destination config. If the secret is missing the delivery fails and says so, rather than sending an empty credential and collecting a confusing 401.
Reserved headers are refused: webhook-* (a config must not be able to forge the signature a receiver verifies), content-type, content-length, host, and hop-by-hop headers.
When a transform runs, data.body is your transform's output (and data.headers includes any headers it set). The untouched inbound request is added as a top-level original block, { body, body_encoding, headers }, and metadata.transformed is true. original is present only when a transform ran. Your output sits inside data.body, so it can use any keys without colliding with the envelope's.
Receiving side (your customers)
Every delivery is signed with Standard Webhooks, so your customers verify with any off-the-shelf library — against the exact delivered bytes as received (the envelope above), not a re-parsed copy. The verifyDelivery SDK helper does this and returns the parsed envelope. See signing.
Send transactional email
The Send side isn't only webhooks. The same accept → queue → deliver → retry → DLQ → replay engine sends transactional email through Amazon SES, with delivery-truth analytics (sent → delivered, bounce/complaint tracking, an org suppression list, per-message timelines). See Send email.
Next
- Send email — transactional email through the same engine.
- API conventions — idempotency, scopes, errors, retry schedule.
- Receive webhooks — the inbound half.
- API reference · MCP server