guide

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:

SchemeFieldsWhat is checked
hmac_sha256_headerheader, timestampHeader?, toleranceSeconds? (default and maximum 300), encoding? hex or base64An 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
stripenoneThe stripe-signature header, five-minute window
svixnonesvix-id, svix-timestamp, svix-signature, five-minute window
shared_tokenheader? (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}}.

KeyWhat it isCreates a person?
person_emailTheir addressYes — 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_idA gemmein person idNo — a read-only lookup
person_tokenThe 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.

CodeStatusMeaning
unknown_receiver404No receiver relay with this name on this app. A deleted one no longer receives; a paused one still does
bad_signature401The signature did not verify against this receiver's secret. Check the secret and the header the scheme expects
body_too_large413The body is over 256 KB
invalid_json400The body is not JSON
receiver_capped429More 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_recorded503The 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

TypeFieldsNotes
write_recordcollection, 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_accessentitlement (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_accessentitlementEnds every live grant of that entitlement the person holds
grant_creditsamount (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_productproduct (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_productproduct (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_planplan (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_planplan (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_personsubject (up to 300), text (up to 10,000), kind? event or accountRides 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_urlurlhttps 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.

CodeStatusMeaning
invalid_definition400One sentence naming the field, why, and what to do
relay_capped400This environment holds 20 relays. Delete one, or fold two into one
name_taken409A relay with this name exists in this environment
version_conflict409The definition moved since it was read. Re-read and reapply
not_replayable409Replay 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.