Receivers
Webhook receivers, whose secret it is, the map, the filter and payment time.
A receiver is a URL of the form POST /hooks/<appId>/<name>.
Receivers are standards, not providers: any sender that follows one of these schemes can
call it. Pick the scheme from the sender's own webhook docs. verify names it:
| Scheme | Fields | What is checked |
|---|---|---|
signature | The signature recipe: header or signatureField, and the optional fields in The signature recipe | A signature over the event, checked with the sender’s algorithm and key. Left at its defaults, an HMAC-SHA256 of the raw body, hex, in the header you name |
stripe | none | The stripe-signature header, five-minute window |
svix | none | Standard Webhooks: webhook-id, webhook-timestamp, webhook-signature (or the same three as svix-*), a whsec_… secret, five-minute window. Clerk and Resend send Standard Webhooks |
shared_token | header? (default x-webhook-token), query? (default token) | The token matches in either place. A query token travels in URLs, so prefer the header. A shared token doesn’t sign the event: a relay on it only writes records and calls your URL unless its trigger sets "trustUnsignedEvents": true (see Unsigned events) |
Whose secret it is. Each relay holds one value the sender’s signatures are checked with, and on every scheme you choose whose it is, on the relay’s page in Webhooks:
- New secret for the sender to sign with. Gemmein makes it and shows it once
(on
svix, a Standard Webhookswhsec_…); give it to the sender in its webhook settings. Use it when the sender lets you set the secret. It is not offered for Stripe’s format, whose sender always makes its own. Thesignatureandshared_tokenschemes start with one. - Paste the sender’s signing secret. When the sender shows its own (Stripe and
Standard Webhooks senders always do; their relays start waiting for it). At least 24 plain
ASCII characters (22 when it mixes upper- and lower-case letters and digits), long, and not
one pattern repeated. Stripe’s starts
whsec_; a Standard Webhooks secret iswhsec_and base64. It is stored and never shown again. - Paste the sender’s public key. On an
ed25519orecdsa-p256-sha256recipe (Discord, SendGrid). The sender signs with its private key and shows the public one: paste it as shown, in hex, base64 or a PEM block. Gemmein never makes this key, so the relay starts waiting for it; asking for one answers400 provider_secret_required.
Your AI stores a sender’s value in Development by piping it:
printf '%s' "$SECRET" | npx gemmein relays secret <name>. It is read from
stdin only, never typed on the command line, printed or logged. In Live, your human pastes it
on the relay’s page, after the code Gemmein emails. Storing a value replaces the current one
at once and is a row in Logs; the value itself never is. A relay that calls your URL also has a
signing secret of its own (see Calling your URL); a relay with no
call_url has none.
The map. Up to twenty names, each a dotted path into the provider's body, such
as events.0.details.customer_email. Three names have a special meaning:
event_idis the dedupe key. A repeat answersduplicate: trueand runs nothing. Without it, the body's hash deduplicates within the same UTC day.event_typeis whatwhenusually reads.- One of three keys names the person (table below). A map that carries two of them is refused when the relay is saved: the id and the token could name different people, and the event would land on whichever was tried first.
Every other name is available in templates as {{mapped.name}}.
| Key | What it is | Creates a person? |
|---|---|---|
person_email | Their address | Yes. Created or fetched the same way as invitePerson: the same daily cap, shown as invited until their first sign-in, and on the record. It invites someone who has never signed in |
person_id | A Gemmein person id | No, a read-only lookup |
person_token | The store account token their app filed with a store (currentUser().storeAccountToken) | No, a read-only lookup |
Only an address can create a person, because an address reaches someone who can be
told they have an account. A value a third party chose that matches nothing names no one.
A key that resolves to nobody is not a refusal: the event is recorded and answered 200,
and the run result reads person_not_found with the key that was tried, never
its value.
Payment time: paid_at
A person who deletes their account and buys again is a new customer; a payment from
before the deletion grants nothing. Map the provider's payment-time field as
paid_at on every relay that grants (access, credits, a product or a plan) so
Gemmein can tell before from after. It takes unix seconds, milliseconds or an ISO date; a
time later than the event's arrival counts as its arrival. Without paid_at, a
payment for a deleted account's address invites no one and grants nothing. It shows on the
Errors page for you to decide: refund it, or grant it by hand if it is a new purchase.
The error never contains the address.
The filter. when filters the mapped fields. Per field, it takes a
literal (an exact match) or an object of operators in the list's filter grammar (see
Data). Its keys must be in the map. A non-match answers 200 with
ignored: "when" and records no event.
A typo in when looks like the provider never
called. Nothing is recorded for an ignored event. Test a new receiver with a real
event before relying on it.
The receiver answers 200 with { received: true, eventId } only once the
event is stored. Storing the event is the acknowledgement, and nothing runs before it.
| Code | Status | Meaning |
|---|---|---|
unknown_receiver | 404 | No receiver relay with this name on this app. A deleted one no longer receives; a paused one still does |
bad_signature | 401 | The signature did not verify against this receiver's secret. Check the secret and the header the scheme expects |
body_too_large | 413 | The body is over 256 KB |
invalid_json | 400 | The body is not JSON |
receiver_capped | 429 | More than 120 verified events in a minute for this app; resetAt says when. Unverified traffic never counts against this limit; it is capped per address instead, and the sender gets source_capped (60 a minute from one address) |
not_recorded | 503 | The event could not be stored. Nothing ran; the provider sends it again |
The URL names no environment. The secret decides which environment's relay receives, so Development and Live may share a name, each with its own secret.
Thanks for telling us.
Thanks for telling us.
Last updated