# Webhooks

> Get Anytime Capital Payments events by webhook: payments, refunds, disputes, invoices and subscriptions, signed with HMAC-SHA256 and retried for 24 hours.

Source: https://anytime-capital.com/docs/webhooks · Markdown: https://anytime-capital.com/docs/webhooks.md · All docs: https://anytime-capital.com/docs/llms-full.txt

Add an endpoint in **Settings → Developers** in your dashboard and we POST a JSON event to it as things happen. Each event wraps the same object the API returns, in `data`, so one parser handles both.

**An event**

```json
{
  "id": "evt_3f9a1c2b7d4e6f8a0b1c2d3e4f5a6b7c",
  "type": "payment.succeeded",
  "created": "2026-10-14T16:02:16.000Z",
  "data": {
    "object": "payment",
    "id": "pay_7Hq2LmX9cR4tVb",
    "status": "paid",
    "amountCents": 12500,
    "amountRefundedCents": 0,
    "netCents": 12088,
    "currency": "usd",
    "description": "Consulting — October",
    "source": {
      "type": "payment_link",
      "id": "pl_4kTz8QwN2xYb6RcD1mPa"
    },
    "customer": {
      "name": "Jordan Lee",
      "email": "jordan@example.com",
      "country": "US"
    },
    "card": {
      "brand": "visa",
      "last4": "4242"
    },
    "paymentMethod": {
      "type": "card",
      "label": "Visa •••• 4242"
    },
    "clientReference": "order_1042",
    "metadata": {
      "campaign": "fall-launch"
    },
    "amountDetails": {
      "subtotalCents": 12500,
      "discountCents": 0,
      "shippingCents": 0,
      "taxCents": 0,
      "customerFeeCents": 0
    },
    "dispute": null,
    "failureMessage": null,
    "createdAt": "2026-10-14T16:02:11.000Z",
    "paidAt": "2026-10-14T16:02:15.000Z"
  }
}
```

## Add an endpoint

1. In **Settings → Developers**, choose **Add endpoint**.
2. Enter an `https://` URL on a public domain name (not an IP address or `localhost`).
3. Choose the events to receive, or every event, including types added later.
4. Copy the endpoint's signing secret (`whsec_…`), shown when you add the endpoint, into a server-side environment variable such as `ANYTIME_WEBHOOK_SECRET`. Owners can **Reveal** it again later.
5. Choose **Send test event** to check your endpoint answers `2xx`.

A business can have up to 10 endpoints, each with its own secret and event selection. Owners and admins manage them, and can replace an endpoint's secret at any time: events signed with the old secret stop verifying immediately, so deploy the new one straight away.

## Event types

| Type | `data` is a | When |
| --- | --- | --- |
| `payment.succeeded` | payment | A card payment went through. Sent once per payment. |
| `payment.failed` | payment | A card payment was declined or blocked. |
| `payment.refunded` | payment | All or part of a payment was refunded. Sent for each refund. |
| `dispute.opened` | payment | A customer disputed a payment with their bank. |
| `dispute.closed` | payment | A dispute was decided: won, lost or closed. |
| `invoice.paid` | invoice | An invoice was paid in full by card. |
| `subscription.created` | subscription | A customer subscribed to one of your plans. |
| `subscription.canceled` | subscription | A subscription ended: canceled, expired or completed. |
| `webhook.test` | test | Sent by “Send test event”, whatever the endpoint subscribes to. |

Payment and dispute events carry the [payment object](https://anytime-capital.com/docs/payments.md), `invoice.paid` the [invoice object](https://anytime-capital.com/docs/invoices.md), and the subscription events a subscription: `id`, `status`, `plan` (`id`, `title`, `amountCents`, `currency`, `interval`), `customer`, `currentPeriodEnd`, `cancelAtPeriodEnd`, `canceledAt` and `createdAt`.

## Delivery and retries

- Respond with any `2xx` status within 10 seconds to acknowledge an event; do slow work after responding.
- Anything else (another status, a redirect, a timeout) is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours, then marked failed.
- Every attempt and response is in the endpoint's delivery log, where you can **Resend** any event.
- If several events in a row fail every retry, we turn the endpoint off and email your owners and admins.
- Delivery is at least once and events can arrive out of order: use `id` (also in the `Anytime-Event-Id` header) to skip events you've handled, and re-read the object from the API when order matters.

| Header | Value |
| --- | --- |
| `Anytime-Signature` | `t=<unix seconds>,v1=<hex HMAC-SHA256>` |
| `Anytime-Event-Id` | The event's `id`. |
| `Anytime-Event-Type` | The event's `type`. |
| `Content-Type` | `application/json` |

## Verifying signatures

Every event carries an `Anytime-Signature` header such as `t=1760630536,v1=5257a869…`. `v1` is the hex HMAC-SHA256, keyed with your endpoint's signing secret (the whole `whsec_…` string), of the timestamp, a period and the raw request body. Compute it yourself, compare in constant time, and reject timestamps more than 5 minutes old so a captured request can't be replayed.

> **Use the raw body:** The signature covers the exact bytes we sent. Verify before parsing, and don't verify a body your framework has already parsed and re-serialised: whitespace and key order would change.

**Node.js (Express)**

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

const app = express();
const secret = process.env.ANYTIME_WEBHOOK_SECRET; // whsec_…

// Read the raw body: the signature covers the exact bytes we sent.
app.post("/webhooks/anytime", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.get("Anytime-Signature") ?? "";
  const parts = Object.fromEntries(header.split(",").map((part) => part.split("=")));
  const body = req.body.toString("utf8");

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.${body}`)
    .digest("hex");
  const signed =
    typeof parts.v1 === "string" &&
    parts.v1.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) <= 300;
  if (!signed || !fresh) return res.status(400).send("Invalid signature");

  const event = JSON.parse(body);
  // The same event can arrive more than once: skip ids you've already handled.
  switch (event.type) {
    case "payment.succeeded":
      // Fulfil the order for event.data.id
      break;
    case "payment.refunded":
      // event.data.amountRefundedCents is the total refunded so far
      break;
  }
  res.sendStatus(200);
});
```

**Next.js route handler**

```typescript
// app/api/webhooks/anytime/route.ts (Next.js App Router)
import crypto from "node:crypto";

export async function POST(request: Request) {
  const secret = process.env.ANYTIME_WEBHOOK_SECRET!; // whsec_…
  const body = await request.text(); // the raw body, exactly as sent
  const header = request.headers.get("Anytime-Signature") ?? "";
  const parts = Object.fromEntries(header.split(",").map((part) => part.split("=")));

  const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${body}`).digest("hex");
  const signed =
    typeof parts.v1 === "string" &&
    parts.v1.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) <= 300;
  if (!signed || !fresh) return new Response("Invalid signature", { status: 400 });

  const event = JSON.parse(body);
  // Skip event.id if you've handled it before, then act on event.type.
  if (event.type === "payment.succeeded") {
    // Mark the order in event.data.metadata (or event.data.clientReference) paid.
  }
  return new Response(null, { status: 200 });
}
```

**Python (Flask)**

```python
# Flask
import hashlib, hmac, json, os, time
from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["ANYTIME_WEBHOOK_SECRET"]  # whsec_…

@app.post("/webhooks/anytime")
def anytime_webhook():
    body = request.get_data()  # the raw bytes, exactly as sent
    parts = dict(p.split("=", 1) for p in request.headers.get("Anytime-Signature", "").split(",") if "=" in p)
    timestamp = parts.get("t", "")
    expected = hmac.new(SECRET.encode(), timestamp.encode() + b"." + body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, parts.get("v1", "")):
        abort(400)
    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
        abort(400)

    event = json.loads(body)
    # Skip event["id"] if you've handled it before, then act on event["type"].
    return "", 200
```

## Handling events well

- Record each `id` you process and skip repeats.
- Fulfil orders on `payment.succeeded`, never on the customer reaching your success page: a bank account payment can take days, and a customer can close the tab.
- Match a payment to your order with `data.metadata` (from the link) or `data.clientReference` (from `?ref=`).
- On `payment.refunded`, `data.amountRefundedCents` is the total refunded so far, not this refund alone.
- On `dispute.opened`, respond from **Disputes** in the dashboard before the deadline.

---

Anytime Capital is a fintech company, not a bank. Payment processing, banking and payout services are provided by Anytime Capital's licensed partners. Rates shown are for US card payments; international cards, currency conversion and disputes may carry additional fees shown in your dashboard before you accept a payment.
