The signature recipe
Describe any sender's signature, worked examples, and unsigned events.
A receiver checks a webhook by its standard, not by who sends it: there is no list of
allowed providers. Read the sender’s own webhook docs for four answers, and the relay’s
verify block records them. Every field is optional except where the signature is;
left out, it is the plain case: an HMAC-SHA256 of the raw body, hex, in one header.
| Question | Field | Values |
|---|---|---|
| Which algorithm? | algorithm | hmac-sha256 (the default), hmac-sha1 or hmac-sha512 with a shared secret; or ed25519 or ecdsa-p256-sha256, where the sender signs with its private key and you store its public key |
| Which key? | the relay’s stored value | A shared secret (one Gemmein makes, or the senderA shared secret (Gemmein’s, or the sender’s pasted), or the sender’s public keyrsquo;s, pasted), or the senderA shared secret (Gemmein’s, or the sender’s pasted), or the sender’s public keyrsquo;s public key (see Whose secret it is) |
| What text is signed? | signedPayload | A template of at most 40 characters: {timestamp}, {url} (the receiver URL exactly as the relay’s page shows it), and one of {body} (the raw body), {params} (a form post’s fields, sorted by name, each name then value) or {token} (a body value named by tokenField), which ends the text. Letters, digits and . : _ - go around them. The default is {timestamp}.{body} with a timestamp, {body} without |
| Where is the signature? | header or signatureField | A header of its own, or a dotted path inside the JSON or form body (signature.signature). Exactly one. When the header carries parts like h1=…, v1=… or sha256=…, signatureKey names the part (any one of several with that name matching is enough, for a secret rotation), and separator is , (the default) or ; |
| Where is the timestamp? | timestampHeader, timestampKey or timestampField | A header of its own, a part inside the signature header (ts=), or a path in the body. One at most. With one, a replay window applies: toleranceSeconds, 1 to 300, default 300. A timestamp verifies only as 1 to 13 digits |
| How is it spelt? | encoding | hex (the default), base64 or base64url |
The save checks the template: each placeholder appears once; a digit or
{params} never comes right after {timestamp}; {timestamp}
must be in the text when a timestamp source is named, and a source is named when
{timestamp} or {token} is used; {body} and
{params} stay out of the text when the signature itself is in the body. Anything
else answers 400 invalid_definition, naming what to fix, and nothing is saved. A
definition that saves but describes the signature differently from how the sender signs (the
timestamp in another place, another separator, other signed text, or a receiver URL given to the
sender with a ?query after it) answers 401 bad_signature on every event,
and nothing runs.
Worked examples
Each recipe below is proven against the sender’s published spec by a test that runs on every Gemmein release. A sender is named here only while its test passes.
| Sender | verify | What is signed |
|---|---|---|
| GitHub | {"scheme": "signature", "header": "X-Hub-Signature-256", "signatureKey": "sha256"} | HMAC-SHA256 of the body, hex, after sha256= in the header |
| GoCardless | {"scheme": "signature", "header": "Webhook-Signature"} | HMAC-SHA256 of the body, plain hex, in the header |
| Lemon Squeezy | {"scheme": "signature", "header": "X-Signature"} | HMAC-SHA256 of the body, plain hex, in the header |
| Shopify | {"scheme": "signature", "header": "X-Shopify-Hmac-Sha256", "encoding": "base64"} | HMAC-SHA256 of the body, base64, in the header |
| Paddle | {"scheme": "signature", "header": "Paddle-Signature", "timestampKey": "ts", "signatureKey": "h1", "separator": ";", "signedPayload": "{timestamp}:{body}"} | HMAC-SHA256 of the timestamp, a colon and the body; ts=…;h1=… in one header |
| Slack | {"scheme": "signature", "header": "X-Slack-Signature", "timestampHeader": "X-Slack-Request-Timestamp", "signatureKey": "v0", "signedPayload": "v0:{timestamp}:{body}"} | HMAC-SHA256 of v0:, the timestamp, a colon and the body; the timestamp in its own header |
| Intercom | {"scheme": "signature", "algorithm": "hmac-sha1", "header": "X-Hub-Signature", "signatureKey": "sha1"} | HMAC-SHA1 of the body, hex, after sha1= |
| Square | {"scheme": "signature", "header": "x-square-hmacsha256-signature", "encoding": "base64", "signedPayload": "{url}{body}"} | HMAC-SHA256 of the receiver URL and the body, base64 |
| Twilio | {"scheme": "signature", "algorithm": "hmac-sha1", "header": "X-Twilio-Signature", "encoding": "base64", "signedPayload": "{url}{params}"} | HMAC-SHA1 of the receiver URL and the form post’s fields, base64 |
| Mailgun | {"scheme": "signature", "signatureField": "signature.signature", "timestampField": "signature.timestamp", "tokenField": "signature.token", "signedPayload": "{timestamp}{token}"} | HMAC-SHA256 of the timestamp and a token, inside the JSON body. The event itself is not signed |
| SendGrid | {"scheme": "signature", "algorithm": "ecdsa-p256-sha256", "header": "X-Twilio-Email-Event-Webhook-Signature", "timestampHeader": "X-Twilio-Email-Event-Webhook-Timestamp", "encoding": "base64", "signedPayload": "{timestamp}{body}"} | ECDSA P-256 over the timestamp and the body, with SendGrid’s public key |
| Discord | {"scheme": "signature", "algorithm": "ed25519", "header": "X-Signature-Ed25519", "timestampHeader": "X-Signature-Timestamp", "signedPayload": "{timestamp}{body}"} | Ed25519 over the timestamp and the body, with your application’s public key |
| Standard Webhooks (Clerk, Resend) | {"scheme": "svix"} | The Standard Webhooks headers and a whsec_… secret |
| Stripe | {"scheme": "stripe"} | Stripe’s Stripe-Signature: t=…,v1=… format. For the app’s own payments, Stripe is built in and needs no relay (see Payments) |
| RevenueCat | {"scheme": "shared_token", "header": "authorization"} | The token you set in RevenueCat’s Authorization header field, repeated on every call. Not a signature (see Unsigned events) |
A form post (Twilio’s) is read as its fields, so a map names them directly:
"event_id": "MessageSid". A sender that signs no timestamp (GitHub, Intercom,
Square, Twilio) has no replay window: a resent event is recognised by its
event_id, or without one by its body within the UTC day, and runs once. Events are
kept 30 days after they finish, so a call to such a sender captured more than 30 days earlier
could run once more. A signature header listing more than 8 signatures answers
400 too_many_signatures.
Names are trademarks of their respective owners and are used only to identify services whose signed webhooks Gemmein can verify. Gemmein is not affiliated with, endorsed by or sponsored by any of them.
Unsigned events
Two kinds of receiver don’t sign the event itself: a {token} recipe
(Mailgun), where the signature covers a timestamp and a token but not the event, so anyone who
captures one signed call can send it with any content, once, within 5 minutes; and
shared_token, where the same token comes on every call, so anyone who has it can
send any number of events at any time. Gemmein accepts each signature of a {token}
or {params} recipe once, still for 24 hours after the relay is deleted.
By default such a relay only writes records and calls your URL. Every other action
(grant_access, grant_credits, grant_plan,
fulfil_product, start_run, revoke_access,
revoke_plan, refund_product, email_person) is refused
when the relay is saved, with 400 invalid_definition, and so is a map naming
person_email, which creates people. The same refusal holds in
gemmein dev, gemmein sync, the dashboard and Promote.
The safer way to act on such a sender: call_url to your function, which checks
the event with the sender’s own API and writes a record with your secret key; a
data_change relay on that collection then acts. A record a relay writes never fires a
data_change relay, so write_record can’t be turned into a grant.
To let the relay act anyway, set the opt-in on its trigger:
"trigger": { "kind": "receiver", …, "trustUnsignedEvents": true }. Your AI asks
you before setting it. The relay’s page shows the warning beside it, Logs records who
turned it on, and Promote carries it to Live with the warning in its preview. The Go live page
lists each such relay with its warning. A save that widens what a captured call buys (a longer
toleranceSeconds, no timestamp, or a move to shared_token) shows the
warning again. The opt-in is refused on a sender that signs the event. The RevenueCat and
shared-token templates on the Webhooks page carry it, with the warning in their note.
| If… | What happens | What you do |
|---|---|---|
| A relay stored without the opt-in breaks the rule | Every event answers 401 bad_signature, nothing runs, and the Errors page shows relay_unsigned_event | Set trustUnsignedEvents, or keep the relay to write_record and call_url |
| An event arrived unsigned, and the relay changed to act without the opt-in before it ran | It ends without running ("…nothing ran"), and replaying it answers 409 unsigned_event | Set the opt-in to let such events act, or let this one go |
| A relay that checks a shared secret holds a public key | Every event is refused, and the Errors page shows relay_key_as_secret | Paste the sender’s signing secret on the relay’s page |
A relay was saved before the scheme was renamed signature | Every event answers 401 scheme_renamed | Save its definition again with "scheme": "signature" |
Thanks for telling us.
Thanks for telling us.
Last updated