Relays
Something happens, and Gemmein acts. A provider's webhook arrives, a clock ticks, or a record changes, and Gemmein writes a record, grants or revokes access, emails the person, or calls your URL. One JSON file describes it. Gemmein runs its own verbs and never your code.
What it is. A rule you write as a file: when this happens, do these things. Gemmein watches for the trigger, runs the actions, keeps a row for every event, retries when something fails, and shows every run in the dashboard with a replay button. Stripe is built in. Any provider that signs its webhooks, GoCardless, Paddle or Lemon Squeezy among them, drives access the same way through a relay.
What it does. Eight actions, and only these: write a record, grant access, revoke access, grant credits, fulfil or refund a product, email the person the event is about, call your URL.
What it does not do. It does not run your code, and it does not call other services for you. Call your URL sends a signed notice to a server you run, with Gemmein's body and Gemmein's signature, not a request you shape, so it cannot call OpenAI or any API that wants your key and its own format. A schedule has no person attached, so "email everyone whose trial ends tomorrow" is not a relay yet. No transforms: an action cannot compute or reshape data. One event acts on one person; emailing a list is a broadcast, not a relay. A relay adds credits and never spends them; a spend is your server's or the AI route's.
When you need something else. Anything that thinks, a model call, a score, a transform, a third API, runs in your own function behind call your URL, which receives a verified event with the person already resolved. The AI route is where Gemmein holds your provider key and makes a chat call for a signed-in person, at the credits you set on the tool it names — one for an unnamed call.
One file, one trigger, up to ten verbs
A relay is gemmein/relays/<name>.json. Your AI writes it.
It names one trigger and one to ten actions, run in order. The dashboard's Relays room is
where relays are created and edited (the definition JSON), paused, resumed, replayed,
rotated and deleted; it shows the receiver URL, the secrets, every event with each
action's result, and where the definition came from (from file, synced when; or edited
here, when). Here is the GoCardless case: a payment
confirms, the payer gets Pro, an email goes out, and your function hears about it.
{
"name": "gocardless-paid",
"trigger": {
"kind": "receiver",
"verify": { "scheme": "hmac_sha256_header", "header": "Webhook-Signature" },
"map": {
"event_id": "events.0.id",
"event_type": "events.0.action",
"person_email": "events.0.details.customer_email"
},
"when": { "event_type": "confirmed" }
},
"actions": [
{ "type": "grant_access", "entitlement": "Pro", "reason": "GoCardless payment {{mapped.event_id}}" },
{ "type": "email_person", "subject": "Your payment is confirmed", "text": "Thanks. Pro is open on your account." },
{ "type": "call_url", "url": "https://hooks.example.com/paid" }
]
}
The name is two to sixty-three lowercase letters, digits and hyphens,
starting with a letter. It is the file name and the last segment of the receiver URL,
so renaming a relay renames its URL. A field the engine does not know is refused
by name, one sentence per problem (invalid_definition), so the JSON is
rewritten from the sentence rather than guessed at.
Selling through a provider without a Payment Link
A product's "How it's sold" can name a relay instead of a Stripe Payment Link —
GoCardless, Lemon Squeezy, Paddle, bank transfer, any provider whose webhook you can map.
fulfil_product grants the product's key and credits to the event's person and
writes the receipt, exactly as a Stripe purchase does; refund_product takes
them back. Both are idempotent on ref. This is also the worked "any provider,
no server of your own" example:
{
"name": "gocardless-paid",
"trigger": {
"kind": "receiver",
"verify": { "scheme": "shared_token" },
"map": {
"event_id": "events.0.id",
"event_type": "events.0.action",
"person_email": "events.0.details.customer_email",
"payment_id": "events.0.links.payment"
},
"when": { "event_type": "confirmed" }
},
"actions": [
{ "type": "fulfil_product", "product": "Starter pack", "ref": "{{mapped.payment_id}}" }
]
}
Mapped fields ride into templates as {{mapped.<name>}};
{{event.<path>}} reads the raw payload.
The refund twin swaps the trigger's when to
{ "event_type": "refunded" } and the action to refund_product.
g.payments.buy on a product sold this way answers 409
product_not_sellable — there is no Payment Link to open. Saving a relay action,
or a product's "How it's sold", against a relay that does not exist in this environment
answers 400 relay_missing.
Subscription plans are sold the same three ways. A plan's "How it's sold" can name
a relay, and two actions run its lifecycle: grant_plan writes the person's
subscription on that plan — active, stamped with the relay event, no Stripe customer id —
grants the plan's key sourced on the subscription row, and records the payment under
ref, exactly the steps a Stripe invoice takes; revoke_plan ends a
relay-granted subscription, lands the person on the default plan and revokes exactly the key
grant_plan gave. Both are idempotent on ref; a renewal is a new
ref that keeps the plan active. A relay-sold plan is active until revoked — there
is no lapse timer; the provider's cancellation webhook is the revoke, on its own relay.
g.subscriptions.checkout on a plan sold this way answers 409
plan_not_sellable.
What a relay road is bound to
The relay road binds by name for fulfilment: renaming or deleting the relay stops
fulfilment until a relay with that name exists again; the product card shows it.
refund_product may run from any relay in the environment; it refunds only a
purchase a relay fulfilled, for the event's person and the product it names.
grant_plan binds the same way — only a plan whose road names this relay;
revoke_plan binds to the grant, not the road: it may run from any relay, and it
never ends a Stripe-billed subscription, which is Stripe's to end.
A ref must be the provider's unique payment identifier; a ref already used
by another purchase is refused and audited. A ref you wrote that names nothing on the
event is refused, never replaced: only an absent ref falls back to
rly:<eventId>.
Receivers: any provider that signs its calls
A receiver is a URL of the form POST /hooks/<appId>/<name>. Any
provider that signs its webhooks can call it. verify names the scheme:
| Scheme | Fields | What is checked |
|---|---|---|
hmac_sha256_header | header, timestampHeader?, toleranceSeconds? (default and maximum 300), encoding? hex or base64 | An HMAC-SHA256 of the raw body with the receiver secret. When a timestamp header is named, the signed text is the timestamp, a dot, then the body, inside the window. GoCardless: header Webhook-Signature, hex, raw body |
stripe | none | The stripe-signature header, five-minute window |
svix | none | svix-id, svix-timestamp, svix-signature, five-minute window |
shared_token | header? (default x-webhook-token), query? (default token) | The token matches in either place. A query token travels in URLs, so the header is the better home |
Secrets. Gemmein mints the receiver secret when the relay is created and shows it once. It is never readable again; rotating mints a new one. The owner pastes it into the provider. A provider that only shows its own secret, such as Stripe or a svix-based sender, is stored through rotate with that value.
The map. Up to twenty names, each a dotted path into the provider's body, such
as events.0.details.customer_email. Three names mean something to the
engine. event_id is the dedupe key: a repeat answers
duplicate: true and runs nothing, and without it the body's hash
deduplicates within the same UTC day. event_type is what when
usually reads. One of three names the person, and 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 rides into templates
as {{mapped.name}}.
| Key | What it is | Creates a person? |
|---|---|---|
person_email | Their address | Yes — created or fetched through the same door 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 is a way to reach a human who
can be told they have an account; a value a third party chose that matches nothing names
no human at all. 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 naming the key that
was tried, never its value.
App stores. A purchase in the App Store or Play reaches Gemmein as a RevenueCat
webhook into a receiver whose map names person_token — the app hands
RevenueCat the person's storeAccountToken as its app user id, so neither the
person id nor the address enters a third party's ledger. The Relays room offers both
templates: purchase → fulfil_product, expiry or cancellation →
revoke_access. The whole road is on Mobile.
The filter. when is an exact string match on the mapped fields,
and 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 row is stored. The row is the acknowledgement; nothing runs before it exists.
| 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 spends this budget; 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 production may share a name, each with its own secret.
Schedules
{ "kind": "schedule", "every": "1d", "at": "09:00" }. every
is one of 15m, 30m, 1h, 6h,
12h or 1d; at is a UTC time and pairs only with
1d. One tick per period. A relay created mid-period first fires next
period. After a gap the most recent missed period runs late, and older missed periods
land as dead rows you can replay. A schedule has no person, so
grant_access, revoke_access, email_person and
to: "person" are refused on it when the file is validated.
Record changes
{ "kind": "data_change", "collection": "bookings", "on": ["created"], "where": { "status": "won" } }.
on is any of created, updated and
deleted; where is an exact match on the record's own data
fields. It fires for writes from your app, from the dashboard's editor and from Stripe
receipts, at most once per write per relay. It never fires for the runner's own
writes, and never for an account erasure: an erased person's data never fans out. A
call_url that writes back through the SDK does fire, so a loop through your
own host is yours to avoid. A definition change applies within five seconds. The person
is the record's owner, otherwise its recipient.
Authorise on fields the person cannot set. A
where on a field the signed-in user can write is a self-service grant: they
set status to won and receive Pro. Filter on a field only the
owner or the dashboard writes, or grant from a receiver, where the provider's signature
is the proof.
Actions
| Type | Fields | Notes |
|---|---|---|
write_record | collection, data, to?: "person" | Keyed per event, so a retry never duplicates. Written through the same door as your app, so the plain-text and file laws apply. to: "person" addresses the record to the event's person and is required on addressed and direct collections. The record is app-owned, like a receipt |
grant_access | entitlement (a plan or product name, or access:<slug>), expiresAt? ("30d", "12h", "2w" or an ISO date), reason? (up to 200 characters, templated) | The seventh grant source, relay. The person's page reads "granted by relay <name>". No key can mint it |
revoke_access | entitlement | Ends every live grant of that entitlement the person holds |
grant_credits | amount (1 to 10,000), reason? (up to 200 characters, templated) | Adds to the person's credit balance, once per event; the ledger line reads "relay: <reason>". A relay adds and never spends |
fulfil_product | product (a product name), ref? (up to 200 characters, templated) | Grants the product's key and credits to the event's person and writes the receipt, exactly as a Stripe purchase does. Idempotent on ref: an absent ref falls back to rly:<eventId>, and a ref you wrote that names nothing on the event is refused, never replaced. Only fulfils a product whose "How it's sold" names this relay |
refund_product | product (a product name), ref? (up to 200 characters, templated) | Takes back what fulfil_product under the same ref granted. May run from any relay in the environment; it refunds only a purchase a relay fulfilled, for the event's person and the product it names. Idempotent on ref |
grant_plan | plan (a plan name), ref? (up to 200 characters, templated) | Writes the event's person's subscription on that plan — active, no Stripe customer id — grants the plan's key sourced on the subscription row, and records the payment under ref, exactly the steps a Stripe invoice takes. Idempotent on ref (an absent ref falls back to rly:<eventId>; a written ref that names nothing is refused); a renewal is a new ref. Only grants a plan whose "How it's sold" names this relay. One active plan per person. Active until revoke_plan |
revoke_plan | plan (a plan name), ref? (up to 200 characters, templated) | Ends the person's relay-granted subscription on that plan: they land on the default plan and the key grant_plan gave is revoked (by-hand and purchase grants stay). May run from any relay in the environment. Never ends a Stripe-billed subscription — the event's result says so |
email_person | subject (up to 300), text (up to 10,000), kind? event or account | Rides notify()'s caps, 200 per app per hour and five event sends per person per day, and the owner's sends switch. It rides notify's sender law too: it goes only from your verified sender domain, wearing that domain's news word (kind account wears its account word), and until one is verified on the Domains page this action alone is refused sender_domain_required and the event's result says so. Nothing is sent via Gemmein |
call_url | url | https only, no template in the URL, no IP literal, never a gemmein.com host, no credentials in the URL. In gemmein dev, http to localhost is allowed |
Calling your URL
Compute lives on your host. call_url is how a relay reaches it.
Gemmein POSTs one JSON body:
{
"id": "…", // the event id, the same on every retry and replay
"relay": { "id": "…", "name": "gocardless-paid" },
"trigger": "receiver", // receiver | schedule | data_change
"event": { … }, // the stored payload: { event, mapped } | { record, previous } | { tick }
"person": { "id": "…", "email": "…" }, // or null
"results": [ … ] // the actions that ran before this one
}
The request carries content-type: application/json,
user-agent: Gemmein-Relays/1 and
X-Gemmein-Signature: t=<unix seconds>,v1=<hex>, where
v1 is the HMAC-SHA256 of the timestamp, a dot, then the body, with the
relay's signing secret. That secret is shown once when the relay is created
and can be rotated. Verify over the raw body, in constant time, before trusting
anything:
import { createHmac, timingSafeEqual } from "node:crypto"
const [t, v1] = req.headers["x-gemmein-signature"].split(",").map(p => p.slice(p.indexOf("=") + 1))
const expected = createHmac("sha256", process.env.GEMMEIN_SIGNING_SECRET).update(`${t}.${rawBody}`).digest("hex")
const ok = expected.length === v1.length && timingSafeEqual(Buffer.from(expected), Buffer.from(v1))
A 2xx answer is done. A redirect is a failure and is never followed. The call times out after ten seconds. The status and the first 4 KB of the answer are kept on the event, so the owner can read what your URL said.
Your URL must deduplicate on id. The
call is not idempotent on Gemmein's side: every retry and every replay POSTs the same
id. Keep the ids you have handled. And the whole event rides in the body,
so the URL receives private data; it is your host, over https.
Refused addresses arrive as the action's error, naming the rule:
https_only, ip_literal, own_host,
unresolvable, private_address. Gemmein calls public addresses
only, checks the address on every attempt, and pins the connection to the address it
checked.
Templates
String values in write_record.data, email_person's subject
and text, and grant_access.reason can carry a path in double braces:
{{event.a.b}} for the raw body, {{mapped.x}},
{{record.data.x}}, {{record.id}},
{{person.email}}, {{person.id}} and
{{tick.periodStart}}. A path that names nothing renders empty and adds a
warning to the event. An object renders as JSON. There are no expressions and no
filters. A template is refused in call_url.url and in an
entitlement.
When something fails
Every trigger lands as a durable event row before any action runs. An event is
queued, then running, then done. A failure retries
after 1 minute, 5 minutes, 30 minutes, 2 hours, 8 hours and 16 hours, seven attempts in
all, and then the event is dead. The owner receives the
relay_failed alert, on by default and switchable on the Alerts page,
and the event's replay button resets the count. A definition-level failure, such as a
suspended person, a missing collection or an unknown entitlement, is dead on the first
attempt.
Actions run in order and stop at the first failure. A retry or a replay runs every
action again. write_record, grant_access,
revoke_access and email_person each find their own earlier work
and record skipped; call_url POSTs again. Every run is one
audit row with each action's before and after, and every grant, email and record an
relay makes is attributed to it by name.
A paused relay still records receiver events and record changes; they wait for resume. Schedule ticks during a pause are not recorded. Events are kept for 30 days.
Limits
Current limits: 20 relays per environment, 10 actions each, 120 events per minute, 32 KB stored per event (a larger body is truncated with a flag, and the mapped fields survive), 256 KB per receiver body. Limits are raised on request: hello@gemmein.com.
| Code | Status | Meaning |
|---|---|---|
invalid_definition | 400 | One sentence naming the field, why, and what to do |
relay_capped | 400 | This environment holds 20 relays. Delete one, or fold two into one |
name_taken | 409 | A relay with this name exists in this environment |
version_conflict | 409 | The definition moved since it was read. Re-read and reapply |
not_replayable | 409 | Replay applies to a dead, failed or done event. A queued or running one is already on its way |
Both rails
gemmein dev runs receivers, schedules and record changes on your machine
with the same runner. The boot card prints each receiver's local URL, and every run
prints RELAY · name · trigger · n actions · ok|failed.
npx gemmein sync carries the files to the cloud app's development
environment with the collections. Sync moves the contract and never data: the cloud
mints its own secrets and shows them in the dashboard. After go-live,
npx gemmein sync --live carries relays and AI tools into production with a
sync key (Secret keys → production → Sync key: your sign-in code to mint, one hour to
live, shown once). It prints what would change, waits for the word live,
never deletes, and asks before replacing a relay edited in the dashboard. Every relay
says where its definition came from — from file, or edited in the dashboard — and when.
A relay's name is fixed once created: the receiver URL is built from it.
Two facts for the reference
- No schedule under fifteen minutes.
- Secrets never sync. A receiver secret minted on your machine stays there; the cloud mints its own and shows it once.