Skip to content

Responses & transformations

Two points in the inbound pipeline are yours to program: the response a provider gets back the instant it sends (computed at the edge), and the transformation that reshapes the payload before Emithook relays it on (run after the event is safely buffered). They sit on opposite sides of the durability boundary, which is why they work differently.

 provider ─▶  edge (instant)                          ┊  processing (after buffer)
             • verify signature → verdict             ┊  • transform(event) ⇒ JSON
             • evaluate response rules                ┊  • relay to destinations
             • buffer + ack ─────────────────────────┘  durably buffered · ack sent

The response is declarative (rules + templates) because it runs on the always-on edge, which acknowledges in milliseconds and never runs your code. The transformation is a sandboxed JavaScript function, because it runs in the processing plane where real logic is safe. Nothing you configure here can cause an event to be dropped — the response is only sent once the event is durably buffered.

The custom response

Every endpoint has an ordered list of rules and a default. Rules are evaluated top‑down, first match wins; the default applies if none match. Each response sets a status code (always — 200, 201, 202, 204, 4xx), optional headers, a content type (a literal or mirror to reflect the request's), and a body.

What a rule can match on: method, path, request headers, query params, and json body fields — with equals, exists, contains, matches (regex), and and/or/not.

What a body can contain (templating, no code):

TokenReturns
{{raw}}the exact received body, byte‑for‑byte (echo)
{{json}}the parsed body re‑serialized as JSON
{{json.path}}a single field, e.g. {{json.challenge}}
{{query.name}}a query param, e.g. {{query.hub.challenge}}
{{headers.name}}a request header value
{{request_id}}the buffered event id (also returned as the webhook-id header)

Guardrails the edge enforces: {{raw}}/{{json}} apply only within a body‑size cap (larger bodies fall back to the default ack), runtime/hop‑by‑hop headers can't be set, the status is clamped to a valid range, and a buffering failure returns 5xx regardless of the rules.

Handshake presets

Some providers' verification handshakes need cryptography that templating can't express. These ship as named presets the edge runs natively:

PresetProvider(s)What it answers
meta-challengeWhatsApp, Facebook, Instagram, MessengerGET hub.challenge echo after matching hub.verify_token
slack-url-verificationSlackurl_verification{ challenge }
msgraph-validationMicrosoft Graphechoes validationToken as text
dropbox-challengeDropboxGET ?challenge= echo
zoom-validationZoomendpoint.url_validation → HMAC‑signed token
ebay-deletioneBayaccount‑deletion challenge (SHA‑256)

Response examples

WhatsApp / Meta — subscription handshake, then a fast ack:

preset: meta-challenge                       # GET hub.challenge + verify_token (403 on mismatch)
default (POST)  → 200  {"request_id":"{{request_id}}"}

Slack — URL verification and an immediate slash‑command reply:

when POST and json.type = "url_verification"
     → 200  application/json  {"challenge":"{{json.challenge}}"}
when POST and json.command exists
     → 200  application/json  {"response_type":"ephemeral","text":"Working on it…"}
default → 200  {"request_id":"{{request_id}}"}

GitHub — a friendly ping, ack otherwise:

when header X-GitHub-Event = "ping"
     → 200  application/json  {"msg":"pong"}
default → 200

Echo the body back with a 201, mirroring content type:

default → 201  contentType: mirror  body: {{raw}}

Echo the parsed JSON with 202 Accepted:

default → 202  application/json  body: {{json}}

Other patterns this covers: custom statuses (201/202/204), rejecting with 403/401 on a bad token, setting a Location header on 201, and conditioning on method (GET handshake vs POST events), header (X-Shopify-Topic), or body field (json.type).

Synchronous proxy (dynamic responses)

Declarative rules cover static, echoed, and templated replies. When a provider needs a reply that's computed — real logic the templates can't express — an endpoint can opt into synchronous proxy mode and write a function (request) ⇒ response.

By default that function runs on Emithook's own service (api.emithook.com) in a sandbox and its result is returned to the sender — so you get full eval power for the reply without running code on the always-on edge. Advanced users can instead point an endpoint at their own URL (the edge forwards the request and returns your service's response).

This is the one response path that depends on the processing plane, so it's designed to give that up without giving up your data:

  • Buffer first. The event is durably buffered before the proxy call, exactly as always — so even if the responder is down, the event is never lost.
  • Then proxy. The edge calls the configured URL with a timeout (3 s by default) and returns its status, headers, and body to the sender.
  • Fallback when the responder is unavailable or doesn't reply within the timeout, chosen per endpoint:
    • fail-soft (default) — the event is already saved; return the standard ack (200 {request_id}). The sender succeeds, you process on recovery. Only the dynamic part of the reply is lost, never the event.
    • fail-hard — return 5xx so the sender retries. The endpoint is "down" for the synchronous answer, but the event is still buffered.

So with the defaults (3 s, fail-soft) a slow or down responder never hangs the sender and never costs you the event: at 3 s the edge stops waiting, the buffered event stands, and the standard ack goes back.

Use it only when you genuinely need a computed reply; the declarative rules stay the default because they're served entirely at the edge with no dependency. Note that a proxied endpoint puts your responder on the request hot path (it must scale to receive volume) and adds a round-trip to each response.

The transformation

A transformation is a JavaScript function that receives the event and returns the JSON to relay. It runs after the event is buffered, in a sandbox (no network, no filesystem, with CPU/memory/time limits), and takes effect on the next event immediately.

js
// (event) => the JSON relayed to destinations
function transform(event) {
  return { /* reshaped payload */ }
}

The event exposes event.json (body), event.raw, event.headers, event.method/event.path, event.request_id, event.endpoint ({ id, slug, url }), event.org, event.receivedAt, and event.verification ({ scheme, result }). Return the payload object, or { payload, headers } to also set outbound headers. If the function errors or times out, the event is parked with the error captured (visible under Incoming requests) rather than relayed malformed.

Transformation examples

WhatsApp — flatten the nested message envelope:

js
function transform(event) {
  const v = event.json.entry?.[0]?.changes?.[0]?.value
  const m = v?.messages?.[0]
  return {
    from: m?.from,
    type: m?.type,
    text: m?.text?.body ?? null,
    received_at: m?.timestamp,
    phone_number_id: v?.metadata?.phone_number_id,
  }
}

Shopify — trim the order and drop PII:

js
function transform(event) {
  const o = event.json
  return {
    order_id: o.id,
    email: o.email,
    total: o.total_price,
    currency: o.currency,
    items: (o.line_items ?? []).map((li) => ({ sku: li.sku, qty: li.quantity })),
  }
}

GitHub — a common envelope across event types:

js
function transform(event) {
  const p = event.json
  return {
    kind: event.headers['x-github-event'],
    action: p.action ?? null,
    repo: p.repository?.full_name,
    sender: p.sender?.login,
  }
}

Branch by type, redact, and wrap with metadata:

js
function transform(event) {
  const p = event.json
  const data = p.type === 'invoice.paid'
    ? { invoice: p.data.object.id, amount: p.data.object.amount_paid }
    : { raw: p }
  return { source: 'emithook', event_id: event.request_id, received_at: event.receivedAt, data }
}

You don't have to hand-roll the original request

Every delivery already carries the original request in the delivery envelope. When a transform runs, your output is delivered with the original request attached under original ({ url, method, headers, data, received_at }) automatically — so a transform can focus purely on shaping, and the consumer still has the full original to fall back on.

What transforms are for: projecting a subset of fields, renaming, adding computed values, redacting PII, flattening nested envelopes, reshaping arrays, branching on event type, wrapping in an envelope, coercing types, and setting outbound headers.

What's covered

ProviderHandshakeAckEchoSignature verifyTransform
WhatsApp / Meta familymeta-challenge200challengepresetflatten
Slackrule200challengesigning secretnormalize
Microsoft Graphrule202tokenper‑tenantper‑need
Shopify200HMAC presettrim + redact
Stripe200signature presetpick fields
GitHubrule (ping)200X-Hub-Signature-256envelope
Twilio / Square200/204signatureper‑need
Zoomzoom-validation200computedHMAC presetper‑need
eBayebay-deletion200computedSHA‑256 presetper‑need
Dropboxdropbox-challenge200challengeper‑need
Generic / customrulesanyanygeneric-hmac / noneany

What isn't supported (by design)

  • Your JavaScript at the edge. The always‑on edge can't run untrusted code, so responses are declarative rules and presets only. Logic that needs computation belongs in the transformation.
  • A response that depends on processing — by default. Declarative replies are computed from the request alone at the edge. The one exception is opt-in synchronous proxy mode, which deliberately trades that endpoint's response availability for a dynamic reply (the event is still buffered first, so data is never at risk).
  • Network, database, or file access inside a transform. It's sandboxed; external enrichment is out of scope.

Next

Emithook · a Finnoto product