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

# Configure Webhooks

export const TxnTag = ({status}) => {
  const getStyles = status => {
    const baseStyle = {
      display: 'inline-block',
      padding: '4px 8px',
      borderRadius: '4px',
      fontSize: '13px',
      fontWeight: '400'
    };
    switch (status) {
      case "PENDING":
        return {
          ...baseStyle,
          backgroundColor: '#ececec',
          color: '#9f9f9f'
        };
      case "AUTHORIZED":
        return {
          ...baseStyle,
          backgroundColor: '#ecfdf5',
          color: '#047857'
        };
      case "SETTLED":
      case "PARTIALLY_SETTLED":
        return {
          ...baseStyle,
          backgroundColor: '#ecfdf5',
          color: '#047857'
        };
      case "DECLINED":
      case "FAILED":
        return {
          ...baseStyle,
          backgroundColor: '#fef2f2',
          color: '#dc2626'
        };
      case "CANCELLED":
        return {
          ...baseStyle,
          backgroundColor: '#fefce8',
          color: '#d78203'
        };
      case "SETTLING":
        return {
          ...baseStyle,
          backgroundColor: '#e0f2fe',
          color: '#0c4a6e'
        };
      default:
        return {
          ...baseStyle,
          backgroundColor: '#f3f4f6',
          color: '#374151'
        };
    }
  };
  return <span style={getStyles(status)}>{status}</span>;
};

## Overview

A webhook is a notification sent from Primer to your server using an HTTP `POST` request.
You can use webhooks to be notified of:

* **Payment status updates**\
  Useful for asynchronous processor connections, where you do not get an immediate authorization response.
* **Refund completion**\
  Notified when a refund reaches a final state.
* **Payment operation failures**\
  Notified when a capture, cancellation, refund, or authorization adjustment attempt fails without changing the payment status.
* **Disputes and chargebacks**\
  Notified when a dispute is opened.
* **Workflow run failures**\
  Recommended if you use Capture or Cancel via Workflows.

## Delivery model (read this first)

This section defines the expected behavior of Primer webhooks.

### What counts as a successful delivery

A webhook delivery is considered **successful** when Primer receives **any HTTP `2xx`** response from your endpoint.
Any response outside the `2xx` range, including `3xx` redirects, is treated as a failure and will trigger retries.

### How fast should my endpoint respond

Your endpoint should return a `2xx` response **immediately** upon receiving the webhook.
Do **not** perform long or complex processing before responding. A good pattern is:

1. Validate signature and basic payload shape
2. Enqueue the work (or store the event)
3. Respond with a `2xx` status code
4. Process asynchronously

### Timeout

Primer uses a **10 second timeout** for webhook delivery attempts.\
If your endpoint does not respond within this window, the attempt is treated as a failure and retries may happen.

### When will I receive webhooks

Webhook delivery typically happens **within seconds** of the underlying change (payment status update, refund completion, dispute opened, etc.), but is not guaranteed to be immediate.

## Retries

If a delivery attempt fails, Primer retries the webhook up to **5 times** with increasing delays.

Typical retry schedule:

* \~10 seconds
* \~60 seconds
* \~5 minutes
* \~10 minutes
* \~15 minutes

Actual retry timings may vary slightly due to random jitter applied to avoid retry bursts, which helps prevent many retries from hitting your server at the same time.

The final retry happens approximately **30 minutes** after the first attempt.

<Note>
  After the final retry attempt, the event is dropped and will not be retried again.
</Note>

## Ordering and event recency

Webhook ordering is **not guaranteed**.
To detect the latest payment status change, compare `payment.dateUpdated` and keep the newest one.

<Warning>
  Never assume the last webhook you received is the newest one.
  Always compare timestamps.
</Warning>

## Delivery guarantees and idempotency

Primer webhooks are delivered **at least once**.

This means that in rare cases, the same webhook event may be delivered more than once. Your system must be able to handle duplicate deliveries safely.

To do this, webhook processing should be idempotent.

Recommended approaches:

* Store a unique key per processed event and ignore duplicates
* Use stable identifiers from the payload, for example:
  * `payment.id` + `payment.dateUpdated` for payment status updates
  * `payment.id` + latest refund transaction timestamp for refunds
  * `transactionId` for disputes

If a stable identifier cannot be derived, a safe fallback is to store a hash of the payload for a short time window and ignore repeated deliveries.

## Set up webhooks

Set up a webhook in the **Developers** area of the Dashboard. Webhooks are sent with a `POST` request to your endpoint.

<Frame caption="Add a webhook">
  <img src="https://mintcdn.com/primer-cc826789/zUtJbc_qshGTc-1X/images/api/add_a_webhook.png?fit=max&auto=format&n=zUtJbc_qshGTc-1X&q=85&s=9529480da5c84ddca6e1258c8f8871d0" width="2048" height="2644" data-path="images/api/add_a_webhook.png" />
</Frame>

## Test webhooks

Click **Test webhook** to send an example request to your endpoint.

<Frame caption="Test a webhook">
  <img src="https://mintcdn.com/primer-cc826789/2gUFYKfNb_svpxsN/images/api/test_a_webhook.png?fit=max&auto=format&n=2gUFYKfNb_svpxsN&q=85&s=f15cea8db78b1679a3ec288d78678fc5" width="1920" height="1004" data-path="images/api/test_a_webhook.png" />
</Frame>

Example payload:

```json theme={"dark"}
{
  "message": "Testing your webhook connection"
}
```

## Webhook event types

### Payment status updates

Payment status notifications are sent whenever a payment status changes.
The webhook payload contains the full payment object.

**Example**

```json theme={"dark"}
{
  "eventType": "PAYMENT.STATUS",
  "date": "2023-02-21T15:36:16.367687",
  "notificationConfig": {
    "id": "cc51f9f0-7e1c-492b-9d37-f83a818f6070",
    "description": "Payment webhook"
  },
  "version": "2.1",
  "payment": {
    "id": "DdRZ6YY0",
    "date": "2023-02-21T15:36:16.167687",
    "dateUpdated": "2023-02-21T15:36:16.267687",
    "amount": 3000,
    "currencyCode": "GBP",
    "customerId": "cust-123",
    "orderId": "order-123",
    "status": "SETTLED",
    "paymentMethod": {
      "paymentMethodToken": "-lcWjvBAAs2DnIRXwxNjUzNTYzNjIy",
      "analyticsId": "LUi5pETUaVsdSEamK25L",
      "paymentMethodType": "PAYMENT_CARD",
      "paymentMethodData": {
        "last4Digits": "1111",
        "expirationMonth": "03",
        "expirationYear": "2030",
        "cardholderName": "ADYEN",
        "network": "Visa",
        "isNetworkTokenized": false,
        "binData": {
          "network": "VISA",
          "issuerCountryCode": "US",
          "issuerName": "JPMORGAN CHASE BANK, N.A.",
          "regionalRestriction": "UNKNOWN",
          "accountNumberType": "UNKNOWN",
          "accountFundingType": "UNKNOWN",
          "prepaidReloadableIndicator": "NOT_APPLICABLE",
          "productUsageType": "UNKNOWN",
          "productCode": "UNKNOWN",
          "productName": "UNKNOWN"
        },
        "cvvAvailable": true
      },
      "threeDSecureAuthentication": {
        "responseCode": "NOT_PERFORMED"
      }
    },
    "processor": {
      "name": "STRIPE",
      "processorMerchantId": "acct_1GORasdasqNWFwi8c",
      "amountCaptured": 3000,
      "amountRefunded": 0
    },
    "transactions": [
      {
        "date": "2023-02-21T15:36:16.167687",
        "amount": 3000,
        "currencyCode": "GBP",
        "transactionType": "SALE",
        "processorTransactionId": "pi_3L3edsGZasdasdc1iget38p",
        "processorName": "STRIPE",
        "processorMerchantId": "acct_1GORasvasdNWFwi8c",
        "processorStatus": "SETTLED"
      }
    ]
  }
}
```

<Note>
  See the [migration guide](/changelogs/migration-guides/payment-status-webhooks) to update to the latest versions of the webhook event.
</Note>

### Refunds

Refund notifications are sent when a refund reaches a final state.
Check the most recent `REFUND` transaction in the payment `transactions`:

* <TxnTag status="SETTLED" /> the refund succeeded and funds were returned
* <TxnTag status="FAILED" /> the refund failed
  **Example**

```json theme={"dark"}
{
  "eventType": "PAYMENT.REFUND",
  "date": "2023-02-21T15:37:16.367687",
  "notificationConfig": {
    "id": "cc51f9f0-7e1c-492b-9d37-f83a818f6070",
    "description": "Refund webhook"
  },
  "version": "2.1",
  "payment": {
    "id": "DdRZ6YY0",
    "date": "2023-02-21T15:36:16.167687",
    "dateUpdated": "2023-02-21T15:37:16.267687",
    "amount": 3000,
    "currencyCode": "GBP",
    "customerId": "cust-123",
    "orderId": "order-123",
    "status": "SETTLED",
    "processor": {
      "name": "STRIPE",
      "processorMerchantId": "acct_1G2EpYaHgVZqNWFwi8c",
      "amountCaptured": 3000,
      "amountRefunded": 3000
    },
    "transactions": [
      {
        "date": "2023-02-21T15:36:16.167687",
        "amount": 3000,
        "currencyCode": "GBP",
        "transactionType": "SALE",
        "processorTransactionId": "pi_3L3ed23NWFwiNWFwi8c1iget38p",
        "processorName": "STRIPE",
        "processorMerchantId": "acct_1GORcaGv23NWFwi8c",
        "processorStatus": "SETTLED"
      },
      {
        "date": "2023-02-21T15:37:16.267687",
        "amount": 3000,
        "currencyCode": "GBP",
        "transactionType": "REFUND",
        "processorTransactionId": "pi_3L3ed23NWFwiNWFwi8c1iget38p",
        "processorName": "STRIPE",
        "processorMerchantId": "acct_1GORcaGv23NWFwi8c",
        "processorStatus": "SETTLED"
      }
    ]
  }
}
```

### Payment operation failures

Failed operation notifications are sent when a capture, cancellation, refund, or authorization adjustment attempt fails but the payment status does not change.

These webhooks use a minimal payload. Call `GET /payments/{id}?expand=transactions.events` to retrieve full details.

| Event type                                | Description                                |
| ----------------------------------------- | ------------------------------------------ |
| `PAYMENT.CAPTURE.FAILED`                  | A capture attempt failed                   |
| `PAYMENT.CANCELLATION.FAILED`             | A cancellation attempt failed              |
| `PAYMENT.REFUND.FAILED`                   | A refund attempt failed                    |
| `PAYMENT.AUTHORIZATION_ADJUSTMENT.FAILED` | An authorization adjustment attempt failed |

**Example**

```json theme={"dark"}
{
  "eventType": "PAYMENT.CAPTURE.FAILED",
  "date": "2026-02-19T15:36:16.367687",
  "notificationConfig": {
    "id": "cc51f9f0-7e1c-492b-9d37-f83a818f6070",
    "description": "Payment webhook"
  },
  "version": "2.4",
  "signedAt": "1689221338",
  "payment": {
    "id": "DdRZ6YY0",
    "amount": 3000,
    "currencyCode": "GBP",
    "orderId": "order-123"
  },
  "requestAmount": 1500,
  "transactionEvent": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
  }
}
```

### Disputes and chargebacks

Dispute notifications are sent when a dispute or chargeback is opened.

| Field                | Description                                  |
| -------------------- | -------------------------------------------- |
| `eventType`          | Always `DISPUTE.OPENED`                      |
| `primerAccountId`    | Your Primer merchant account id              |
| `transactionId`      | Primer transaction id related to the dispute |
| `orderId`            | Your order reference                         |
| `processorId`        | Processor name                               |
| `processorDisputeId` | Dispute id on the processor side             |
| `paymentId`          | Primer payment id                            |
| **Example**          |                                              |

```json theme={"dark"}
{
  "eventType": "DISPUTE.OPENED",
  "version": "2.1",
  "primerAccountId": "7fcd50f1-99f2-416e-8013-6ecd1c1285c3",
  "transactionId": "c3f662ad-d197-492e-b78b-63eefa64a31d",
  "orderId": "order-123",
  "processorId": "Adyen",
  "processorDisputeId": "adyen_ref_123",
  "paymentId": "ecb8d3bc-805d-4d97-826e-ef8d4cc3d2a2"
}
```

### Workflow run failed

This webhook is recommended if you use Capture or Cancel via Workflows.
A workflow failure does not necessarily fail the payment or update the payment status.
For more details, see the [Automation documentation](/workflows/monitor-workflows/workflow-run-failed-webhook).

```json theme={"dark"}
{
  "eventType": "WORKFLOW_RUN.FAILED",
  "version": "1.0",
  "date": "2024-02-21 15:36:16.167687",
  "primerAccountId": "123abcde-99f2-416e-8013-6ecd1c1285c3",
  "triggerEventId": "DdRZ6YY0",
  "workflow": {
    "id": "ecb8d3bc-123a-4d56-826e-ef8d4cc3d2a2",
    "name": "MIT UK Card",
    "version": 8
  },
  "run": {
    "timestamp": "2024-03-07T12:20:14.394429",
    "id": "bbb1c3cc-805d-4d97-826e-ef8d4cc3d2a2",
    "status": "FAILED",
    "lastError": {
      "applicationId": "PRIMER_PAYMENTS",
      "actionId": "capture_payment",
      "diagnosticsId": "1234567890",
      "message": "Payment ID not found."
    }
  }
}
```

## Webhook signing

Primer can sign webhook events so you can verify that they were sent by Primer.
Each webhook request includes the following headers:

* `X-Signature-Primary`
* `X-Signature-Secondary` only present during signing secret rotation

The signature is an HMAC SHA256 of the raw webhook payload using your signing secret, base64 encoded.

### Prevent replay attacks

Each webhook payload includes a `signedAt` field, representing the Unix timestamp of when the webhook was signed.
When validating a webhook, you should verify that:

* the signature is valid
* the `signedAt` timestamp is close to your current system time\
  Primer recommends a maximum difference of 3 minutes
  If Primer retries a webhook, it is signed again. Each retry has a new `signedAt` value and a new signature.

### Verify signatures (Python example)

```python theme={"dark"}
import base64
import hashlib
import hmac

def validate_webhook_signature(payload: str, signature: str, secret: str) -> bool:
    mac = hmac.new(
        key=secret.encode("utf-8"),
        msg=payload.encode("utf-8"),
        digestmod=hashlib.sha256,
    )
    computed = base64.b64encode(mac.digest()).decode("utf-8")
    return computed == signature
```

### Set up your signing secret

Create a signing secret from the **Webhooks** section of the Primer Dashboard.
Signing secrets are environment specific. Each environment (Sandbox and Production) has its own secret.
You can only copy the secret once. Make sure to store it securely.

### Rotate your signing secret

You can rotate your active signing secret from the Dashboard at any time.
When a secret is rotated:

* the previous secret remains valid for 24 hours
* both signatures are sent during that period
  Webhook request headers during rotation:
* `X-Signature-Primary` signed with the new secret
* `X-Signature-Secondary` signed with the previous secret
  During the transition window, your endpoint should verify the webhook against both signatures.

```python theme={"dark"}
@router.post("/my-webhook")
async def my_webhook(request: Request, payload: dict):
    if "X-Signature-Primary" in request.headers:
        verify(request.headers["X-Signature-Primary"], payload)
    if "X-Signature-Secondary" in request.headers:
        verify(request.headers["X-Signature-Secondary"], payload)
    return {"status": "ok"}
```

## Checklist

If you experience missing or repeated webhook events:

* Ensure your endpoint responds with a `2xx` status code quickly
* Avoid HTTP redirects
* Handle duplicate deliveries safely
* Compare `payment.dateUpdated` to determine the latest state
* Verify webhook signatures and `signedAt` timestamps
