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.

That's the whole value. If you have to tell us an order failed, you already knew. What Relay catches is the ones nobody reported.

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.

Keys are shown once. We store only a SHA-256 hash, so we can't recover a key for you — and neither can anyone who gets into our database. Lost a key? Revoke it and make a new one.

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

POST https://YOUR-PROJECT.supabase.co/functions/v1/reconcile

Body

FieldTypeNotes
order_id requiredstringYour identifier. Re-sending the same one updates that order rather than creating a second.
agreed requiredobjectWhat the customer agreed to buy.
charged requiredobjectWhat your processor actually charged.
confirmedobjectWhat 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.

FieldWhereEnables
amountagreed, chargedprice drift detection
currencyagreed, chargedcurrency switch detection
capture_countchargedduplicate charge detection
statusconfirmedticketing failure, partial orders, and whether to hold the order open
routeagreed, confirmedwrong-route detection
depart_dateagreed, confirmedwrong-date detection
passengeragreed, confirmedname mismatch (case and spacing are normalised first)
refundableagreed, confirmedfare-rule drift
refund_initiated_atchargedstarts the refund-settlement watch
refund_settled_atchargedends it
charge_id, processor, supplierstored for your reference

Response

FieldMeaning
verdictcleared or flagged.
flagsArray of { code, severity, detail }. Empty when cleared.
severitycritical, warning, or null.
resolutionPlain-language recommendation for the worst flag present.
stateopen = still waiting on an outcome. settled = finished.
awaitingWhat we're waiting for: ticketing, partial, or refund.
expected_byWhen 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

CodeSeverityRaised when
unissued_after_chargecriticalMoney was taken and the supplier order failed — or never confirmed before the deadline.
duplicate_chargecriticalcapture_count > 1. Usually a retry after a timeout.
partial_ordercriticalSupplier confirmed only part of the itinerary.
price_driftcriticalCharged amount differs from agreed by more than 0.01.
currency_mismatchcriticalCharged in a different currency than quoted.
refund_not_landedcriticalRefund initiated more than 5 days ago and never settled.
date_mismatchcriticalConfirmed departure date differs from what was sold.
route_mismatchcriticalConfirmed route differs from what was sold.
passenger_mismatchwarningConfirmed passenger name differs (after normalising case and spacing).
fare_rule_driftwarningSold refundable, confirmed non-refundable.
Think a flag is wrong? Mark it as a false alarm in the console. That's not a courtesy — it's how the thresholds get tuned, and it's what lets us show a real precision figure instead of a claim.

GET /v1/orders

GET https://YOUR-PROJECT.supabase.co/functions/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

ParameterValuesNotes
verdictflagged · clearedWhat is broken, or what passed.
stateopen · settledopen means Relay is still waiting on someone.
flaga flag codeOnly orders carrying that specific failure.
limit1–100Default 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.

Off by default, and approve-first. Remediation is disabled per workspace until you turn it on, and even then a plan does not execute until it is approved. Both rules are enforced by database constraints, not by the function — a redeployed function with a bug in it still cannot move money that nobody approved.

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

FlagActionAmount
unissued_after_chargestripe.refund_fullThe whole charge
duplicate_chargestripe.refund_duplicateOnly the surplus captures
price_driftstripe.refund_differencecharged − agreed
partial_orderduffel.cancel_order
passenger_mismatchduffel.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

POST https://YOUR-PROJECT.supabase.co/functions/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

POST https://YOUR-PROJECT.supabase.co/functions/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

GET https://YOUR-PROJECT.supabase.co/functions/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 }
}
Coverage counts resolved outcomes, not proposed plans. A plan nobody executed has fixed nothing. Flags with 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

StatusCodeWhat to do
400invalid_requestA required field is missing. The response lists which.
400invalid_jsonBody wasn't valid JSON.
401unauthorizedMissing, unknown, or revoked API key.
405method_not_allowedUse POST.
500server_errorOurs. 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.