Skip to content

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:

PatternUse it whenHow it resolves
Direct sendOne-off / system-to-system push, no event mapping.POST /v1/send names a registered destination id or a validated HTTPS URL.
Static fan-outAn 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:

bash
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" }
  }'
json
// → 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):

typeBrokerFIFO within a groupDelivery countConformance
sqsAWS SQSFIFO queues (MessageGroupId)ApproximateReceiveCountCI (ElasticMQ)
natsNATS JetStreamstream orderJetStream redelivery countCI
redisRedis Streamsstream orderXCLAIM delivery countCI
amqpRabbitMQqueue orderquorum-queue x-delivery-countCI (RabbitMQ)
kafkaApache Kafkaper-key partition orderadapter-tracked per offsetCI (Redpanda)
pubsubGoogle Pub/Submessage ordering (orderingKey)deliveryAttempt (via DLQ policy)CI (emulator)
azureAzure Service Busqueue orderdeliveryCountconformance-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:

bash
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" } }'
json
// → 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:

json
{
  "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" }
  }
}
  • version is the envelope version. Switch on it if you ever need to handle more than one.

  • metadata.identifier / name tell 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 on identifier when many webhooks feed one destination, because it stays the same if you rename the endpoint or move it to another domain.

  • metadata.event_id equals the webhook-id header. Use it to drop duplicate retries.

  • metadata.verification says whether Emithook checked the provider's signature on the way in (verified / failed / unverified) and which scheme it used. Both are recorded at the same moment, so a later change to the endpoint's settings can't make them disagree.

  • data.body is decoded for you. metadata.body_encoding says how:

    body_encodingInbound bodydata.body
    jsona 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 belowthe 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 text instead:

    • 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_encoding rather 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 interactive payload= form field. When a json or form body has a string value that holds a JSON object or array, data.body carries it parsed, and metadata.expanded_json_paths lists 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.10 vs 1.1, \u00e9 vs é, 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 SNS Message relayed through Emithook, rebuild it from the original request (the request log, or Mirror for HTTPS destinations) rather than from data.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.headers are the inbound headers, minus secrets Emithook won't pass on (authorization, cookie, set-cookie, proxy-authorization). data.queries is the parsed query string, one value per key ({} when there is none). If a key is repeated, it keeps its last value; metadata.url has the full query string.

  • metadata.content_type and size (in bytes) describe the inbound request.

  • metadata.captured_at is when Emithook received the request. metadata.posted_at is when Emithook posted this delivery to your destination: each retry and each replay carries its own, so posted_at − captured_at is how long the event took to reach you. On HTTPS deliveries it is the same instant as the webhook-timestamp header.

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:

ts
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 parsed

Triggering 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 transformtransform output in data.body, request under originaltransform output only
Without onethe original request, wrappeda pure relay of the inbound body
webhook-* headersalwaysonly 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:

json
{
  "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 ​

Emithook · a Finnoto product