Skip to main content

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.
  • 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.
After the final retry attempt, the event is dropped and will not be retried again.

Ordering and event recency

Webhook ordering is not guaranteed. To detect the latest payment status change, compare payment.dateUpdated and keep the newest one.
Never assume the last webhook you received is the newest one. Always compare timestamps.

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.

Add a webhook

Test webhooks

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

Test a webhook

Example payload:
{
  "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
{
  "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"
      }
    ]
  }
}
See the migration guide to update to the latest versions of the webhook event.

Refunds

Refund notifications are sent when a refund reaches a final state. Check the most recent REFUND transaction in the payment transactions:
  • SETTLED the refund succeeded and funds were returned
  • FAILED the refund failed Example
{
  "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"
      }
    ]
  }
}

Disputes and chargebacks

Dispute notifications are sent when a dispute or chargeback is opened.
FieldDescription
eventTypeAlways DISPUTE.OPENED
primerAccountIdYour Primer merchant account id
transactionIdPrimer transaction id related to the dispute
orderIdYour order reference
processorIdProcessor name
processorDisputeIdDispute id on the processor side
paymentIdPrimer payment id
Example
{
  "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.
{
  "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)

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.
@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