> ## Documentation Index
> Fetch the complete documentation index at: https://docs.moove.xyz/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Moove POSTs a signed JSON body to your server when a payment link is paid or completes, so you reconcile on an event instead of a poll.

Register an HTTPS endpoint and Moove POSTs a signed JSON body to it every time one of your payment links is paid, and again when a payment completes the link.

<Info>
  Webhooks are the push half of [Moove Payment Links](/api-reference/moove-receive/moove-payment-links). The pull half — `GET /v1/payment-link` — still works, and remains the way to reconcile state you may have missed.
</Info>

***

## Register an endpoint in the console

Endpoints are registered in the Moove Business console at [moove.xyz/business/manage/webhooks](https://www.moove.xyz/business/manage/webhooks), under **Add Endpoint**. There is no API call for this.

<Warning>
  **Registering an endpoint deliberately requires a signed-in session, not an API key.** A key that could register a destination could quietly copy every settled payment to a host its holder controls. Keys create and read payment links; a person decides where notifications go.
</Warning>

Two fields:

<ParamField body="Endpoint URL" type="string" required>
  A public `https://` address with no explicit port — any port in the URL is rejected, even `:443`. Maximum 2048 characters.

  **It cannot be changed later.** To move to a new destination, add a second endpoint and delete the first — both fire until you delete the old one, so the swap has no gap.
</ParamField>

<ParamField body="Name" type="string" required>
  Your label for the endpoint, so you can tell `prod` from `staging`. Maximum 100 characters.
</ParamField>

The URL is validated before anything is saved. These are rejected:

| URL                                      | Why                                       |
| ---------------------------------------- | ----------------------------------------- |
| `http://example.com/webhooks`            | `https` only                              |
| `https://example.com:8443/webhooks`      | No explicit port — `:443` is rejected too |
| `https://user:pass@example.com/webhooks` | No credentials in the URL                 |
| `https://localhost/webhooks`             | Must resolve to a public address          |
| A hostname that does not resolve         | Cannot be validated, so it is refused     |

The hostname is resolved and **every** address it returns must be publicly routable. It is checked again at send time — a host that stops resolving publicly stops receiving deliveries.

<Note>
  You can hold up to **5** endpoints at once. Deleted endpoints do not count towards it.
</Note>

### The signing secret is shown once

Adding an endpoint returns its signing secret. It starts with `whsec_`, it is displayed exactly once, and it is never shown again — not in the list, not through the API.

Store it before you dismiss the dialog. If you lose it, add a new endpoint and delete the old one; there is no rotation and no way to re-read it.

The console lists a short prefix of the secret afterwards, which identifies the endpoint but cannot be used to sign anything.

***

## Events

Every endpoint receives every event. There is no subscription picker.

| `type`                               | When it fires                                                                                                               |
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- |
| `payment_link.transaction.succeeded` | A payment against the link has settled. Fires **once per payment** — a link with `maxUsage` above 1 fires it more than once |
| `payment_link.completed`             | A payment has completed the link — the requested amount is reached or `maxUsage` is hit. Fires **once**, at that transition |

A final payment on a single-use link fires both: `payment_link.transaction.succeeded` for the money and `payment_link.completed` for the link. They are delivered separately and can arrive in either order.

Deactivating a link or letting it expire sends **no event**. Pick those up from [`GET /v1/payment-link`](/api-reference/moove-receive/moove-payment-links#list-payment-links).

Branch on `type`. Ignore any type you do not recognise rather than erroring — new types can be added.

***

## The request Moove sends

`POST` to your URL, `Content-Type: application/json`, with three headers:

| Header            | Value                                                                                                                                                 |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Moove-Signature` | `v1=` followed by the HMAC-SHA256 hex digest. See [verification](#verify-every-delivery)                                                              |
| `Moove-Timestamp` | Unix seconds at which this attempt was signed. Changes on every retry                                                                                 |
| `Moove-Event-Id`  | Stable id for this delivery to this endpoint. **Identical across all retries** — de-duplicate on it. Each endpoint gets its own id for the same event |

### Body

<ResponseField name="id" type="string" required>
  The event id. Matches the `Moove-Event-Id` header.
</ResponseField>

<ResponseField name="type" type="string" required>
  `payment_link.transaction.succeeded` or `payment_link.completed`.
</ResponseField>

<ResponseField name="createdAt" type="string" required>
  ISO 8601 timestamp of when the event occurred — **not** when this attempt was sent. It is identical on every retry.
</ResponseField>

<ResponseField name="data" type="object" required>
  The payment link's state at send time.

  <Expandable title="data">
    <ResponseField name="paymentLinkId" type="string" required>The link's id. Pass it to [`GET /v1/payment-link/{id}`](/api-reference/moove-receive/moove-payment-links#retrieve-a-payment-link) for the full record.</ResponseField>
    <ResponseField name="status" type="string" required>`active`, `completed` or `inactive` — the same values [list](/api-reference/moove-receive/moove-payment-links#list-payment-links) returns.</ResponseField>
    <ResponseField name="amount" type="string" required>The amount the link requests, in the settlement token. This is the `toAmount` you created the link with.</ResponseField>
    <ResponseField name="receivedAmount" type="string" required>Total settled against the link so far. `"0"` when nothing has been received.</ResponseField>
    <ResponseField name="currentUsage" type="integer" required>Payments settled against the link so far.</ResponseField>
    <ResponseField name="maxUsage" type="integer | null">The usage cap, or `null` when the link is unlimited.</ResponseField>
    <ResponseField name="chainId" type="string" required>The settlement chain id.</ResponseField>
    <ResponseField name="tokenAddress" type="string" required>The settlement token's contract address.</ResponseField>
    <ResponseField name="description" type="string | null">What the payer sees. Your invoice reference, if you set one.</ResponseField>

    <ResponseField name="transaction" type="object">
      The settling payment. Present on `payment_link.transaction.succeeded`; **absent** on `payment_link.completed`, which is about the link rather than any one payment.

      <Expandable title="transaction">
        <ResponseField name="id" type="string" required>The transaction id.</ResponseField>
        <ResponseField name="status" type="string" required>`settled`, `processing` or `failed`.</ResponseField>
        <ResponseField name="amount" type="string" required>Amount settled, in the destination token.</ResponseField>
        <ResponseField name="tokenAddress" type="string" required>The destination token's contract address.</ResponseField>
        <ResponseField name="chainId" type="string" required>The destination chain id.</ResponseField>
        <ResponseField name="sourceTransaction" type="string | null">The payer's transaction hash, on the chain they paid from. That can differ from `chainId`, which is the destination; the payload carries no source chain id. This is the field to reconcile on.</ResponseField>
        <ResponseField name="dateCreated" type="string" required>ISO 8601 timestamp.</ResponseField>
      </Expandable>
    </ResponseField>
  </Expandable>
</ResponseField>

```json payment_link.transaction.succeeded theme={null}
{
  "id": "7d4c1b60-3e28-4a91-b5cf-8a0e2d47f913",
  "type": "payment_link.transaction.succeeded",
  "createdAt": "2026-09-13T11:04:22.318000+00:00",
  "data": {
    "paymentLinkId": "0c8f2e5a-4b91-4c3e-9d17-2f6a8b0d1e34",
    "status": "completed",
    "amount": "49.99",
    "receivedAmount": "49.99",
    "currentUsage": 1,
    "maxUsage": 1,
    "chainId": "8453",
    "tokenAddress": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
    "description": "INV-2026-0142",
    "transaction": {
      "id": "3f9a7c12-6b04-4e88-9a1d-5c0b7e2f4a66",
      "status": "settled",
      "amount": "49.99",
      "tokenAddress": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
      "chainId": "8453",
      "sourceTransaction": "0x9c2e5b7a1f04d83e6a5c0b92f7d148ae30c6b5f21d94e08a7c3b6f52d1084e97",
      "dateCreated": "2026-09-13T11:04:19.882000+00:00"
    }
  }
}
```

<Warning>
  **The body on the wire is compact and its keys are sorted** — no spaces, no newlines. The example above is formatted for reading. Verify the signature against the **raw bytes** you received; re-serialising the parsed object produces a different digest.
</Warning>

<Note>
  Nothing about the payer is included. The payer is a third party, and whatever your own checkout collected is already yours.

  The payload is a notification, not a replacement for the API. When you need more than it carries, read the link back with its `paymentLinkId`.
</Note>

***

## Verify every delivery

Your endpoint is a public URL. Anyone can POST to it, so treat an unsigned or badly signed request as hostile.

The signature is `v1=` followed by the hex HMAC-SHA256 of `{Moove-Timestamp}.{raw body}`, keyed by your signing secret.

<Warning>
  The key is the secret **exactly as it was shown to you, including the `whsec_` prefix**. Do not strip it.
</Warning>

<CodeGroup>
  ```python Python theme={null}
  import hashlib
  import hmac
  import time

  TOLERANCE_SECONDS = 300

  def verify(raw_body: bytes, signature: str, timestamp: str, secret: str) -> bool:
      # Reject anything too old to be a live delivery, before spending a comparison on it.
      if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
          return False

      message = timestamp.encode() + b"." + raw_body
      expected = "v1=" + hmac.new(
          secret.encode(), message, hashlib.sha256
      ).hexdigest()

      # Constant time, so a timing difference cannot leak the digest.
      return hmac.compare_digest(expected, signature)
  ```

  ```typescript TypeScript theme={null}
  import { createHmac, timingSafeEqual } from 'node:crypto';

  const TOLERANCE_SECONDS = 300;

  export function verify(
    rawBody: Buffer,
    signature: string,
    timestamp: string,
    secret: string,
  ): boolean {
    if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) {
      return false;
    }

    const message = Buffer.concat([Buffer.from(`${timestamp}.`), rawBody]);
    const expected = `v1=${createHmac('sha256', secret).update(message).digest('hex')}`;

    const a = Buffer.from(expected);
    const b = Buffer.from(signature);
    return a.length === b.length && timingSafeEqual(a, b);
  }
  ```
</CodeGroup>

Four rules:

1. **Read the raw body**, before any JSON middleware touches it. Most frameworks need to be told to keep it.
2. **Compare in constant time.** A plain string comparison leaks the digest a byte at a time.
3. **Reject stale timestamps.** The timestamp is inside the signed message, so it cannot be edited independently — a few minutes of tolerance is enough to stop a captured delivery being replayed later.
4. **De-duplicate on `Moove-Event-Id`.** Delivery is at-least-once; see below. The id is per endpoint, so two endpoints receiving the same event see different ids.

***

## Reply fast, then do the work

<ParamField path="Any 2xx" type="acknowledgement" required>
  The delivery is done. Nothing else is sent for that event.
</ParamField>

Everything else is a failure and enters the retry schedule, including a `3xx`. **Redirects are not followed** — a redirect on a signed POST would replay the body, and its signature, to a host you never registered.

The connection times out after **10 seconds**. Verify, enqueue, return `200`. Do not settle an order, call a payment processor or send an email before replying.

***

## Retries run for about 15 hours

A failed attempt is retried up to **6 attempts in total**:

| Attempt | Sent after the previous one |
| ------- | --------------------------- |
| 1       | Immediately                 |
| 2       | 30 seconds                  |
| 3       | 5 minutes                   |
| 4       | 30 minutes                  |
| 5       | 2 hours                     |
| 6       | 12 hours                    |

After the sixth, the delivery is marked failed and abandoned. Nothing is re-sent, and the event is not replayed when your endpoint recovers — reconcile the gap with [`GET /v1/payment-link`](/api-reference/moove-receive/moove-payment-links#list-payment-links).

<Warning>
  **Delivery is at-least-once.** An attempt whose response was lost after your server had already processed it will be retried, so the same `Moove-Event-Id` can arrive twice.

  Make your handler idempotent: record the event id and drop one you have already applied. This is the one piece of integration work webhooks genuinely require.
</Warning>

Disabling or deleting an endpoint abandons anything still queued for it. A disabled endpoint is a pause, not a buffer — re-enabling it does not replay what it missed.

***

## Inspect what was sent

Each endpoint in the console has **View deliveries**: the event type, status, attempt count, the HTTP code your server returned, and the error text where there was one.

| Delivery status | Meaning                          |
| --------------- | -------------------------------- |
| `Pending`       | In flight, or waiting on a retry |
| `Delivered`     | Your server returned a 2xx       |
| `Failed`        | Every attempt was used           |

The endpoint list also shows the last delivery outcome and a consecutive-failure count — enough to answer "is my webhook working" without opening the log.

The response body your server returns is never stored, and never shown back to you.

***

## What is not available

<Note>
  **Registering, editing and deleting endpoints are console actions**, not API calls — see [above](#register-an-endpoint-in-the-console).

  There is no endpoint test button, no manual replay of a failed delivery, and no secret rotation. To change a secret or a URL, add a new endpoint and delete the old one.
</Note>

## Next

<CardGroup cols={3}>
  <Card title="Moove Payment Links" icon="link" href="/api-reference/moove-receive/moove-payment-links">
    The endpoints these events are about.
  </Card>

  <Card title="Errors" icon="triangle-alert" href="/api-reference/errors">
    Branching on `code`.
  </Card>

  <Card title="Moove Agentic Payments" icon="sparkles" href="/transact/moove-agentic-payments">
    The wider integration.
  </Card>
</CardGroup>
