Skip to navigation

Handle webhooks

Thin events, signed to the Standard Webhooks specification.

Runledger tells you when something happens that you didn’t directly request: a confirmation window closed, a rule reversed a charge, a dispute deadline is near, an invoice finalized. Register an endpoint with POST /v1/webhook_endpoints and choose the event types you handle.

The payload

Events are thin. They say what happened and to which object, and you fetch the object for its current state:

{
"id": "whe_0pL3kJ6hGf",
"object": "webhook_event",
"type": "outcome_charge.reversed",
"api_version": "2026-10-18",
"related_object": {
"id": "oc_2xK9cL4vRt",
"object": "outcome_charge",
"url": "/v1/outcome_charges/oc_2xK9cL4vRt"
},
"reason": { "type": "rule" },
"livemode": false,
"created_at": "2026-11-04T13:12:09Z"
}

Events about a window, a threshold or a purchase also carry a context that names the customer, member_id, team_id, pool, allowance, spend limit and threshold they are about, so you can write a message without another call. Events that report a share of a window or limit point related_object at the customer’s usage summary.

Fetching the object means an out-of-date event can never overwrite newer state. When the cause was an API request, reason carries its request_id and idempotency_key, so you can match the event to your own call.

Verify deliveries

Deliveries follow the Standard Webhooks specification, so its open-source libraries verify them. Each delivery has three headers:

  • webhook-id: unique per event and the same on every retry. Use it to drop duplicates.
  • webhook-timestamp: when this attempt was signed. Reject attempts more than five minutes old.
  • webhook-signature: v1, followed by a base64 HMAC-SHA256 of id.timestamp.body, keyed with the endpoint’s whsec_ secret.
import express from "express";
import { Webhook } from "standardwebhooks";
const app = express();
const wh = new Webhook(process.env.RUNLEDGER_WEBHOOK_SECRET!);
app.post("/webhooks/runledger", express.raw({ type: "application/json" }), (req, res) => {
// Throws if the signature or timestamp is wrong.
const event = wh.verify(req.body, req.headers as Record<string, string>) as {
type: string;
related_object: { id: string; object: string };
};
// Queue the event, then respond within 15 seconds.
res.sendStatus(200);
});

Verify against the raw body, before any JSON parsing changes it. While a secret is rotating, deliveries carry two signatures and either one passes.

Delivery

  • At least once, so deduplicate on webhook-id.
  • In no guaranteed order. Fetch the object instead of relying on order.
  • Any 2xx within 15 seconds counts as delivered. Anything else is retried with exponential backoff for three days.
  • An endpoint that fails every delivery for five days is disabled automatically, and status becomes disabled. Each endpoint reports last_success_at, last_failure_at, failing_since and disables_at, so you can see a failing endpoint before it is disabled. Set status back to enabled with PATCH /v1/webhook_endpoints/{id} once the endpoint works again.
  • Missed events are not replayed automatically. An event that failed all its retries stays failed, and events that happened while an endpoint was disabled are not sent to it when you enable it again. Redeliver is manual and per event: find them with GET /v1/webhook_events?delivery_status=failed (or by endpoint), and send each one with POST /v1/webhook_events/{id}/redeliver. The list keeps the last 30 days, so redeliver within that window.
  • POST /v1/test_helpers/webhook_endpoints/{id}/send_event sends a signed sample event to one endpoint in the sandbox, so you can test a handler and its signature check.
  • GET /v1/webhook_events lists the last 30 days, so you can catch up after an outage, and POST /v1/webhook_events/{id}/redeliver sends one event again, to one endpoint or to every enabled endpoint subscribed to its type. Nothing redelivers for you. Each event has a delivery_status of pending, succeeded or failed, and the list filters by delivery_status and by endpoint.

Event types

TypeObjectSent when
outcome_charge.createdOutcome chargetrack() reported an outcome
outcome_charge.confirmedOutcome chargeThe confirmation window closed, a dispute was upheld, or you confirmed early
outcome_charge.disputedOutcome chargeA dispute opened on it
outcome_charge.reversedOutcome chargeA rule, a dispute or you reversed it
outcome_charge.lockedOutcome chargeIts reversal horizon closed, or a dispute that was holding the lock past the horizon was upheld
reversal.createdReversalAny reversal
dispute.createdDisputeYou or a rule opened a dispute
dispute.decision_dueDispute24 hours before decide_by
dispute.resolvedDisputeYou decided, or the default applied
hold.expiredHoldA hold lapsed and was released
balance.depletedCustomerA balance’s available reached zero
balance.lowUsage summaryA pool’s available balance fell to the plan’s top_up.low_balance_at, or to the threshold of the customer’s auto top-up
usage.threshold_reachedUsage summaryA usage window reached one of its alert_at shares. Once per window per threshold
spend_limit.threshold_reachedUsage summarySpend reached one of a spend limit’s alert_at shares. Once per period per threshold
spend_limit.updatedSpend limitA person, your backend or the customer view changed a spend limit
auto_top_up.succeeded, auto_top_up.failedAuto top-upAn automatic purchase was paid, or its payment failed
auto_top_up.disabledAuto top-upA rule stopped after three failed payments in a row
credit_grant.expiringCredit grantCredits with time left to run are within the account’s grant_expiry_notice of expiring. See Credit expiry
credit_grant.createdCredit grantCredits were granted
credit_grant.expiredCredit grantUnused credits expired
credit_grant.voidedCredit grantA grant was voided
customer.created, customer.updatedCustomerA customer changed
subscription.created, subscription.updatedSubscriptionA subscription started or changed
subscription.renewedSubscriptionA new period began
subscription.past_dueSubscriptionAn invoice went unpaid
subscription.canceledSubscriptionA subscription ended
invoice.createdInvoiceA new period’s draft invoice opened
invoice.finalizedInvoiceAn invoice was finalized
invoice.paidInvoiceThe provider collected payment
invoice.payment_failedInvoiceCollection failed
checkout_session.completedCheckout sessionThe customer paid
checkout_session.expiredCheckout sessionThe page expired unpaid
export.ready, export.failedExportAn export finished
payment.disputedPaymentA payment provider reported a chargeback on a top-up or invoice payment. Separate from outcome disputes, and it changes no credits
reconciliation.completedReconciliationThe daily check finished
reconciliation.breaks_foundReconciliationThe ledger and the provider disagree
test_clock.readyTest clockA test clock finished advancing
rate_card.activatedRate cardA rate card became the active version of its cost model
catalog.price_changedCatalog priceRunledger published a new price version for a catalog model one of your cost models uses. Fetch it to see the rates, the source and when they take effect
margin_guardrail.breachedMargin guardrailWork fell below the guardrail’s floor
simulation.completedSimulationA simulation finished
proposal.pendingConfig proposalA proposal is waiting for a person to approve it
topup_chargeback.threshold_exceededPaymentThe top-up chargeback rate, or the credits at risk, passed the threshold a notification rule set; context carries the figures and the rule

New types may be added; subscribe only to the ones you handle.

See also