Skip to Content
Sending Marshal a Webhook

Sending Marshal a Webhook

Marshal can take an inbound webhook from any system you own — no integration has to be written for it first. You get a URL and a signing secret, your system signs each request, and a deputy mission fires on the events you pick.

This page is the contract. Hand it to whoever writes the sending side.

Getting a URL

An org admin enables it once, in Settings → Webhooks:

  1. Install the generic-hmac-webhook package from the Marketplace.
  2. Press Connect on its row.
  3. Copy the delivery URL and the signing secret.

The secret is shown once. Marshal keeps only what it needs to verify a signature and cannot show it to you again. Put it in your own secret store before you close the panel. If you lose it, disconnect and reconnect — which issues a new URL as well as a new secret.

The URL looks like this, and the token in it is the only thing that identifies your connection — treat it as a credential:

POST https://hooks.marshal.codes/webhook/conn/<token>

Signing a request

Every request carries three headers.

HeaderValue
X-TimestampThe current time, as Unix seconds.
X-SignatureHMAC-SHA256 of <X-Timestamp>.<raw body>, keyed with your signing secret, hex-encoded. sha256=<hex> is accepted too.
X-Event-TypeYour name for the event, e.g. invoice.paid. Optional — see below.

Two rules decide whether your integration works:

  • Sign the raw bytes you send. Not a re-serialized copy. If your HTTP client re-encodes the body after you sign it, the signature will not match.
  • Sign the timestamp with it. The string is the timestamp, a literal ., then the body. Marshal rejects a request whose X-Timestamp is more than five minutes from its own clock, in either direction, so a request captured off the wire cannot be replayed later.
URL="https://hooks.marshal.codes/webhook/conn/REPLACE_WITH_TOKEN" SECRET="REPLACE_WITH_SECRET" BODY='{"invoice":"inv_123","amount":4200}' TS=$(date +%s) SIG=$(printf '%s.%s' "$TS" "$BODY" \ | openssl dgst -sha256 -hmac "$SECRET" -hex \ | sed 's/^.* //') curl -sS -X POST "$URL" \ -H "Content-Type: application/json" \ -H "X-Timestamp: $TS" \ -H "X-Signature: $SIG" \ -H "X-Event-Type: invoice.paid" \ --data-raw "$BODY"

Already sending webhooks in another format?

If the system that will post to Marshal already emits signed webhooks, it probably speaks one of these. Choose that format when you create the endpoint and change nothing on the sending side.

FormatHeaders it sendsWhat it signs
Marshal (default)X-Signature, X-Timestamp<unix-seconds>.<body>, hex
StripeStripe-Signature: t=…,v1=…<t>.<body>, hex
Standard Webhookswebhook-id, webhook-timestamp, webhook-signature: v1,…<id>.<timestamp>.<body>, base64
SlackX-Slack-Signature, X-Slack-Request-Timestampv0:<timestamp>:<body>, hex
Body onlyX-Hub-Signature-256 (GitHub) or X-Shopify-Hmac-SHA256 (Shopify)the body alone

An endpoint verifies with exactly one of these — the one you chose. A request signed correctly in a different format is refused, because an endpoint that accepted any of them would only ever be as strong as the weakest one a sender happened to pick.

Body only has no replay protection. Nothing it signs says when the request was made, so a delivery captured from a log or a proxy stays valid for ever, and re-posting it runs your mission again. Anyone who has seen one valid delivery can repeat that delivery — they still cannot forge a new one without the secret. Choose it only when the sending system offers nothing better.

Two notes for the formats that are somebody else’s specification. Standard Webhooks secrets are written whsec_ followed by base64, and the signature is computed over the decoded bytes — which is what the official SDKs do, so if you use one it will work unchanged. Stripe’s header may carry several v1 signatures while a secret is rolling; any one of them matching is accepted, and the v0 it sends for test events is ignored.

Naming your events

X-Event-Type is the name a deputy trigger binds to. Pick names with a family prefix — invoice.paid, invoice.refunded, deal.created — and a trigger can bind to one (invoice.paid), to the family (invoice.*), or to everything (*).

Send no header and every delivery arrives as *. That still works: the trigger binds to * and its condition sorts out what the event was. But then every binding wakes on every delivery, so the header is worth setting.

What comes back

StatusMeaning
202Accepted and queued. The body carries a message_id.
404Either the URL token is unknown or the request did not verify.
400The body could not be read.
413The body is over 10 MiB.

A 404 deliberately does not say which of the two it was. Answering “that token is real, your signature was wrong” would let anyone with a guessed URL confirm it exists. While you are building the sending side, that makes the failures look identical — so check, in order: the timestamp is Unix seconds (not milliseconds), your clock is within five minutes, you signed <timestamp>.<body> rather than the body alone, and you signed the exact bytes you sent.

Accepted and refused requests both show up under Settings → Webhooks, in one timeline, newest first. A refused one says which of three things went wrong — so the 404 your sender received stays uninformative, while the person who owns the endpoint can see the real reason:

Reason shownWhat to change
Signature did not matchThe secret, or the bytes you signed. Sign the exact bytes you send.
Timestamp too oldYour clock, almost always. The window is five minutes either way.
Format not supportedNothing on your side — the endpoint declares a format this relay cannot check. Tell your Marshal contact.

The body of a refused request is not kept unless the endpoint’s owner turns that on, because a refused request is unverified: it is whatever anybody who found the URL chose to post. Turn it on while you are getting set up, and off again afterwards.

Older refusals are dropped once an endpoint has stored 50 of them. The timeline says so when that has happened, so the oldest one you can see is never mistaken for the first one that happened.

Rotating your secret

The endpoint’s owner can issue a new signing secret without changing the URL, from Settings → Webhooks. The old secret keeps working for 24 hours, so you can deploy the new one without losing a delivery: during that window either secret verifies.

If a secret has leaked, there is a second option that skips the overlap entirely. It stops the old secret immediately — which means your sender fails until it has the new one. That is the point.

Rotating twice inside the window retires the older secret at once. There is only ever one retiring secret, never a chain.

Changing your format

An endpoint’s format can be changed later, again without a new URL or a new secret — useful if you move your sender to a different library. Moving to Body only removes replay protection, and nothing on your side signals that it happened, so Marshal warns before confirming and records the change in the audit trail.

Reaching a deputy

A delivery on its own does nothing until a mission is bound to it. In the mission’s Triggers, add one with:

  • Sourcegeneric-hmac-webhook
  • Event — the X-Event-Type you send, or a family, or *
  • Condition (optional) — a CEL expression over the payload, e.g. event.amount > 1000
  • Inputs — which fields of the payload reach the agent. Only what you map is passed on.

Two deliveries carrying the same id, event_id, delivery_id, webhookId or eventId fire the trigger once, so a retry on your side is safe.