Events & money movement

Webhooks.

Axle calls your server when something happens, with a signed JSON event. Verify the signature, respond quickly with a 2xx, and de-duplicate on the event ID.

Registering an endpoint

Add endpoints on the merchant's Webhooks tab. Each endpoint has a URL and the list of event types it wants.

  • The URL must be HTTPS. Axle re-checks it at delivery time and refuses to call private or internal addresses.
  • Subscribe to event types by their exact name. There's no wildcard: an endpoint only receives the types it lists.
  • A signing secret is shown once, when the endpoint is created. Store it as a secret in your own environment; it can't be shown again.

Event types

TypeFires when
payment.succeededA payment has been verified on-chain and is final. This is your fulfilment signal.
payment.failedThe on-chain transaction failed or didn't match.
payment.canceledThe merchant canceled a pending payment.
refund.processedA refund transaction was verified on-chain.
dispute.openedA dispute was opened (disputes sandbox).
dispute.resolvedA dispute was resolved (disputes sandbox).

Payload

Every delivery is an HTTP POST with a JSON body and these headers:

headers
Content-Type: application/json
Axle-Signature: t=1789639600,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
body
{
  "id": "3f1c9a52-…",
  "type": "payment.succeeded",
  "created": 1789639600,
  "livemode": true,
  "api_version": "2026-08-03",
  "data": {
    "object": {
      "id": "9d94…",
      "merchantId": "5cb9…",
      "method": "crypto",
      "status": "success",
      "amountMinor": 4900,
      "currency": "USD",
      "customerEmail": "customer@example.com",
      "customerName": null,
      "failureReason": null,
      "livemode": true,
      "paymentLinkId": "dbc5…",
      "createdAt": "2026-09-17T10:02:11.000Z",
      "updatedAt": "2026-09-17T10:06:40.000Z"
    }
  }
}

data.object is the payment record at the moment of the event. It doesn't include the nested crypto object, so call GET /v1/payments/:id when you need the transaction hash or confirmation count. created is a Unix timestamp in seconds.

Verifying signatures

Anyone can POST to your URL, so verify every request. The signature covers the timestamp and the exact raw body:

  • Parse the header Axle-Signature into t (Unix seconds) and v1 (hex).
  • Compute HMAC-SHA256(secret, t + "." + rawBody) and hex-encode it.
  • Compare with v1 using a constant-time comparison.
  • Reject the request if t is more than 5 minutes from your clock, so a captured request can't be replayed later.

Node.js

javascript
import crypto from "node:crypto";
import express from "express";

const app = express();

// Verify against the RAW body. Don't run express.json() on this route.
app.post("/webhooks/axle", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.header("Axle-Signature") ?? "";
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const timestamp = parts.t;
  const signature = parts.v1;
  if (!timestamp || !signature) return res.sendStatus(400);

  // Reject stale timestamps (replay protection).
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return res.sendStatus(400);

  const rawBody = req.body.toString("utf8");
  const expected = crypto
    .createHmac("sha256", process.env.AXLE_WEBHOOK_SECRET)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(signature, "hex");
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(400);

  const event = JSON.parse(rawBody);
  // ...de-duplicate on event.id, then handle event.type
  res.sendStatus(200);
});

Python

python
import hashlib
import hmac
import time


def verify_axle_signature(raw_body: bytes, header: str, secret: str) -> bool:
    try:
        parts = dict(p.split("=", 1) for p in header.split(","))
        timestamp = parts["t"]
        signature = parts["v1"]
        if abs(time.time() - int(timestamp)) > 300:  # replay protection
            return False
    except (KeyError, ValueError):
        return False

    expected = hmac.new(
        secret.encode(),
        f"{timestamp}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, signature)

Delivery and retries

Your responseWhat Axle does
Any 2xxMarks the delivery delivered.
Non-2xx, or a network errorRetries with exponential backoff, up to 5 attempts in total, starting at 5 seconds.
A redirect (3xx)Treated as a permanent failure and not retried. Axle never follows redirects.
  • Respond fast. Do the minimum to record the event and return 200, and do slower work afterwards.
  • De-duplicate. Retries mean you can receive the same event more than once. Use id as your idempotency key.
  • Don't rely on ordering. Events can arrive out of order across retries. When it matters, re-read the payment.

Polling as a fallback

If you can't receive webhooks, or want to backfill after downtime, list events with your API key. Results are cursor-paginated.

curl
curl "https://axle-production-fa2b.up.railway.app/v1/events?limit=20" \
  -H "Authorization: Bearer $AXLE_API_KEY"
response 200
{
  "object": "list",
  "data": [
    {
      "object": "event",
      "id": "3f1c9a52-…",
      "type": "payment.succeeded",
      "created": 1789639600,
      "livemode": true,
      "api_version": "2026-08-03",
      "data": { "object": { "id": "9d94…", "status": "success" } }
    }
  ],
  "has_more": false
}

Pass limit (1 to 100, default 20) and startingAfter (an event ID) to page through. See Errors & limits for the rate limit that applies.