WEBHOOKS

Two systems: events you receive, and a rule you own.

Outbound events tell your systems what happened: register an endpoint and APPEN POSTs a signed event at each step of the intent lifecycle. webhook_check runs the other direction — it is the fourteenth rule kind, and it makes your own HTTPS endpoint part of the decision, before any credential or money exists. Both use the same signature, so you write one verifier. No payload ever carries a card number; cards appear as a handle plus last four.

Register an endpoint

Endpoint registration is a human-owner action. The URL is checked against private, loopback, link-local, and cloud-metadata addresses at registration and again on every call. The signing secret is returned once.

endpoint CRUD
POST   /webhook-endpoints    { "url": "https://hooks.example.com/appen", "label": "prod" }
→ 201  { "id": "whe_…", "secret": "whsec_…" }   # save it — shown once

GET    /webhook-endpoints                        # never returns the secret
DELETE /webhook-endpoints/{id}                   # revoke

Verify a signature

Every POST carries X-APPEN-Signature: t=<unix>,v1=<hex>, an HMAC-SHA256 of "<t>.<body>" with your endpoint secret. Reject stale timestamps to stop replay.

verifier (Python)
import hashlib, hmac, time

def verify(secret: str, body: bytes, sig_header: str, tolerance_s: int = 300) -> bool:
    parts = dict(kv.split("=", 1) for kv in sig_header.split(","))
    if abs(time.time() - int(parts["t"])) > tolerance_s:
        return False
    expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + body,
                        hashlib.sha256).hexdigest()
    return hmac.compare_digest(parts["v1"], expected)

Outbound events — live today

Delivery is best-effort with a durable record: each event writes a delivery row you can audit, then POSTs once. Envelope: { id, type, created, data }, where data is the intent with its per-rule trace. Dedupe on id.

intent.declaredevery POST /intents, carrying the resolved status and the per-rule trace
intent.approvedan intent resolves to approved
intent.declinedan intent resolves to denied
intent.pending_approvala human gate parks the intent for sign-off
approval.grantedan owner approves and the re-run passes
approval.deniedan owner denies a parked intent
intent.confirmedsettlement is confirmed against the intent

Designed, not live — these ship when card issuance goes live with our issuing partner:

authorization.approved / .declinedthe card-swipe path — lands with issuance
card.funded / .paused / .resumed / .closedcard lifecycle — lands with issuance
rule.trippedper-rule alerting on fail or review

webhook_check — your own rule

Create a rule with kind: webhook_check and APPEN POSTs each declared intent to your endpoint before deciding. You return { "decision": "approve" | "decline" | "review" }. The default is fail-closed: a slow or unreachable endpoint declines the spend — it cannot wave it through. Local rules run first, so an intent a local rule already declined never reaches your server.

wire your service in as a rule
POST /rules
{
  "kind": "webhook_check",
  "config": { "endpoint_id": "whe_…", "fail_mode": "closed" }
}

# your endpoint receives the PAN-free intent, HMAC-signed, and answers:
{ "decision": "decline", "reason": "vendor not in tonight's run" }

WHERE THIS RUNS TODAY

The rule engine decides every declared intent today — live in the API, the MCP server, and the demo. Enforcement on real card swipes begins at our BIN connection with our issuing partner, when the same pre-authorized, user-directed policies execute inside the network authorization step. Card issuance opens by invite once our issuing-partner sandbox is live.