API reference
Relay checks that every travel order actually delivered what was paid for. You send three views of an order; Relay tells you whether they agree, and chases the ones that haven't resolved yet.
Overview
One endpoint does the work. Call it after checkout with what you know, and call it again whenever you learn more — a ticket issuing, a refund settling. Relay merges what you send, judges it, and stores the result.
The part that isn't obvious: Relay holds orders open. A ticket that hasn't issued yet isn't a failure, it's a ticket that hasn't issued yet. So an order that's charged but unconfirmed comes back as state: "open" with a deadline. If nothing arrives before that deadline, Relay flags it on its own — without you having to notice.
Authentication
Every request carries an API key as a bearer token. Create one in the console under API Keys.
Authorization: Bearer sk_test_a1b2c3...
Sandbox keys (sk_test_) run against test data and never touch live orders. Live keys are issued once we've reviewed real data with you.
Quickstart
The smallest useful call — a charge that went through while the supplier failed:
curl -X POST https://YOUR-PROJECT.supabase.co/functions/v1/reconcile \
-H "Authorization: Bearer sk_test_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"order_id": "ord_9F2k",
"agreed": { "amount": 2480, "currency": "USD" },
"charged": { "amount": 2480, "currency": "USD", "capture_count": 1 },
"confirmed": { "supplier": "duffel", "status": "failed" }
}'
{
"order_id": "ord_9F2k",
"verdict": "flagged",
"flags": [
{ "code": "unissued_after_charge", "severity": "critical",
"detail": "Charged 2480 but supplier order status is \"failed\"." }
],
"resolution": "Reverse the charge — money was taken and no product was delivered.",
"severity": "critical",
"state": "settled",
"reconciled_at": "2026-08-31T18:40:12.284Z"
}
POST /v1/reconcile
Body
| Field | Type | Notes |
|---|---|---|
order_id required | string | Your identifier. Re-sending the same one updates that order rather than creating a second. |
agreed required | object | What the customer agreed to buy. |
charged required | object | What your processor actually charged. |
confirmed | object | What the supplier confirmed. Omit it entirely when the supplier has not responded yet — Relay stores the order, infers no mismatch from the silence, and holds it open against the ticketing deadline rather than rejecting the call. |
Fields inside the three objects
All optional. Send what you have — a field you omit is never treated as a mismatch. The more you send, the more Relay can check.
| Field | Where | Enables |
|---|---|---|
amount | agreed, charged | price drift detection |
currency | agreed, charged | currency switch detection |
capture_count | charged | duplicate charge detection |
status | confirmed | ticketing failure, partial orders, and whether to hold the order open |
route | agreed, confirmed | wrong-route detection |
depart_date | agreed, confirmed | wrong-date detection |
passenger | agreed, confirmed | name mismatch (case and spacing are normalised first) |
refundable | agreed, confirmed | fare-rule drift |
refund_initiated_at | charged | starts the refund-settlement watch |
refund_settled_at | charged | ends it |
charge_id, processor, supplier | — | stored for your reference |
Response
| Field | Meaning |
|---|---|
verdict | cleared or flagged. |
flags | Array of { code, severity, detail }. Empty when cleared. |
severity | critical, warning, or null. |
resolution | Plain-language recommendation for the worst flag present. |
state | open = still waiting on an outcome. settled = finished. |
awaiting | What we're waiting for: ticketing, partial, or refund. |
expected_by | When we'll flag it if nothing arrives. |
Order lifecycle
1. Checkout POST /v1/reconcile confirmed.status = "pending"
→ { state: "open", awaiting: "ticketing",
expected_by: "…20 minutes from now" }
2a. Ticket issues POST /v1/reconcile confirmed.status = "issued"
→ { state: "settled", verdict: "cleared" }
Done. Nothing was wrong.
2b. Nothing arrives (you do nothing)
→ deadline passes, Relay flags it itself
→ webhook fires with unissued_after_charge
Default waits: 20 minutes for ticketing, 30 minutes for a partial itinerary, 5 days for a refund to settle. These are tunable per account once we've measured how long your suppliers actually take.
Flag reference
| Code | Severity | Raised when |
|---|---|---|
unissued_after_charge | critical | Money was taken and the supplier order failed — or never confirmed before the deadline. |
duplicate_charge | critical | capture_count > 1. Usually a retry after a timeout. |
partial_order | critical | Supplier confirmed only part of the itinerary. |
price_drift | critical | Charged amount differs from agreed by more than 0.01. |
currency_mismatch | critical | Charged in a different currency than quoted. |
refund_not_landed | critical | Refund initiated more than 5 days ago and never settled. |
date_mismatch | critical | Confirmed departure date differs from what was sold. |
route_mismatch | critical | Confirmed route differs from what was sold. |
passenger_mismatch | warning | Confirmed passenger name differs (after normalising case and spacing). |
fare_rule_drift | warning | Sold refundable, confirmed non-refundable. |
GET /v1/orders
Read back what Relay is tracking. Authenticated with the same Relay API key as
/v1/reconcile — a server-side integration has a key and no browser session, so this
is how it asks “what is broken right now” without a human logging in.
Scoped to the key's own workspace and its own mode: a sk_test_ key can never
see live orders.
Query parameters
| Parameter | Values | Notes |
|---|---|---|
verdict | flagged · cleared | What is broken, or what passed. |
state | open · settled | open means Relay is still waiting on someone. |
flag | a flag code | Only orders carrying that specific failure. |
limit | 1–100 | Default 20. |
curl "https://YOUR-PROJECT.supabase.co/functions/v1/orders?verdict=flagged&limit=5" \
-H "Authorization: Bearer sk_test_..."
A single order by your own identifier:
GET /v1/orders/ord_9F2k
→ { "order": { "external_id": "ord_9F2k", "verdict": "flagged",
"flags": ["unissued_after_charge"], "state": "settled", ... } }
404 not_found if Relay has never seen that order.
Fixing, not just reporting
Relay can carry out the fix as well as name it: refund a duplicate capture, refund in full when a ticket never issued, refund the difference on price drift, cancel a partially-confirmed supplier order.
The two steps
Propose works out what the fix would be and executes nothing. Approve runs it. They are separate on purpose: the amount is computed by Relay from the stored order and never from the request body, so a caller asking to “remediate order X” cannot influence how much money moves.
What each flag maps to
| Flag | Action | Amount |
|---|---|---|
unissued_after_charge | stripe.refund_full | The whole charge |
duplicate_charge | stripe.refund_duplicate | Only the surplus captures |
price_drift | stripe.refund_difference | charged − agreed |
partial_order | duffel.cancel_order | — |
passenger_mismatch | duffel.request_correction | — |
currency_mismatch, date_mismatch,
route_mismatch, refund_not_landed and fare_rule_drift have
no automatic action. The safe move depends on facts Relay does not have — whether the
traveller still wants the trip, whether the fare can be reissued. Those are escalated to a
person rather than guessed at. | ||
POST /v1/remediate/propose
Returns one plan per flag on the order. Nothing is executed.
curl -X POST .../v1/remediate/propose \
-H "Authorization: Bearer sk_test_..." \
-H "Content-Type: application/json" \
-d '{"order_id": "ord_9F2k"}'
→ {
"order_id": "ord_9F2k",
"remediation_enabled": true,
"plans": [{
"id": "9c1f…",
"flag_code": "duplicate_charge",
"action": "stripe.refund_duplicate",
"amount": 2480.00,
"currency": "USD",
"rationale": "2 captures recorded for one order. Refund the 1 surplus capture(s).",
"status": "proposed"
}],
"message": "Proposed. Nothing has been executed — approve a plan to act on it."
}
Proposing twice returns the same plan rather than creating a second one. The idempotency key is derived from the workspace, order, flag and action, so “refund this duplicate” can never become two refunds however many times it is asked for.
POST /v1/remediate/approve
Approves a plan and runs it. /reject declines one with a reason;
/execute re-runs a plan that failed.
curl -X POST .../v1/remediate/approve \
-H "Authorization: Bearer sk_test_..." \
-H "Content-Type: application/json" \
-d '{"plan_id": "9c1f…"}'
→ { "plan": { "status": "succeeded", "provider": "stripe",
"provider_ref": "re_3P…", "attempts": 1 },
"executed": true }
The same idempotency key is sent to Stripe, so if this call times out after the refund has already been issued, the retry returns the original refund instead of making a second one.
A 409 refused_by_database means one of two things, and the response says which:
the workspace kill switch is off, or the plan is not approved. Do not retry — fix the cause.
GET /v1/remediate/coverage
How much of what Relay detects it actually resolves, per flag.
→ {
"by_flag": [
{ "flag_code": "duplicate_charge", "action": "stripe.refund_duplicate",
"detected": 8, "planned": 8, "resolved": 7, "pending": 1,
"automatable": true, "coverage_pct": 87.5 },
{ "flag_code": "date_mismatch", "action": "escalate.notify",
"detected": 8, "resolved": 0, "automatable": false, "coverage_pct": 0.0 }
],
"totals": { "detected": 80, "resolved": 41, "coverage_pct": 51.3 }
}
automatable: false sit at zero by design, and that is the honest
number rather than one inflated by counting intentions.
Webhooks
Set an endpoint in the console and Relay posts each verdict to it, so you can act on a flag without polling. This is also the only way you hear about orders flagged by timeout, since nothing prompted them.
POST your-endpoint
X-Relay-Signature: 3f9a2b…
{
"event": "order.flagged",
"order_id": "ord_9F2k",
"verdict": "flagged",
"flags": [{ "code": "unissued_after_charge", "severity": "critical",
"detail": "No confirmation received 23 minutes past the expected deadline." }],
"resolution": "Reverse the charge — money was taken and no product was delivered.",
"severity": "critical",
"detected_by": "timeout"
}
Verify the signature
Always. An unverified webhook endpoint accepts anything anyone posts to it.
import { createHash } from "node:crypto";
app.post("/webhooks/relay", express.raw({ type: "application/json" }), (req, res) => {
const expected = createHash("sha256")
.update(process.env.RELAY_WEBHOOK_SECRET + req.body.toString())
.digest("hex");
if (req.get("X-Relay-Signature") !== expected) return res.sendStatus(401);
const event = JSON.parse(req.body.toString());
// handle event.flags …
res.sendStatus(200);
});
Use the raw request body, not a re-serialised object — re-encoding changes the bytes and the signature won't match. Your signing secret is in the console under Webhooks.
Errors
| Status | Code | What to do |
|---|---|---|
| 400 | invalid_request | A required field is missing. The response lists which. |
| 400 | invalid_json | Body wasn't valid JSON. |
| 401 | unauthorized | Missing, unknown, or revoked API key. |
| 405 | method_not_allowed | Use POST. |
| 500 | server_error | Ours. Safe to retry — see idempotency below. |
Idempotency
order_id is the idempotency key. Sending the same order twice updates it rather than creating a duplicate, so retrying after a timeout is always safe — which matters, given that a retry-after-timeout is one of the failure modes Relay exists to catch.
What we store
The three objects you send, verbatim, so a disputed verdict can always be re-derived from the original inputs. Raw payloads are purged after 90 days; verdicts and flags are kept without personal data.
We never receive or store card numbers — only your processor's charge references. That keeps Relay outside your PCI scope, and we intend to keep it that way: there is no field in our schema for a card number.
Every customer's data is isolated at the database level by row-level security, not by application code, so a bug in our UI cannot expose your orders to anyone else.