# Guide to GoCardless Webhooks: Features and Best Practices

GoCardless webhooks notify your application about bank debit activity: a mandate is created or cancelled, a payment is confirmed or fails, a payout is paid. If you're building on GoCardless for bank debit or open banking payments, webhooks are how you react to these events without polling.

This guide covers how GoCardless webhooks work, the event batches you'll handle, how to verify the `Webhook-Signature`, and the best practices for production.

## What are GoCardless webhooks?

GoCardless webhooks are JSON POSTs delivered to a URL you configure. A single POST carries an array of events (`events: [...]`) with up to 250 events per request. Each is signed with a `Webhook-Signature` header (no `X-` prefix, no scheme prefix in the value), computed over the raw POST body with your webhook endpoint's secret. GoCardless doesn't name the algorithm in its docs; the signature values are 64-character lowercase hex, consistent with HMAC-SHA256, so treat that as an informed inference rather than a documented fact. The official Node SDK verifies for you, which sidesteps the ambiguity.

## GoCardless webhook features

| Feature | Details |
| --- | --- |
| Configuration | Developers > Webhook endpoints |
| Signature header | `Webhook-Signature` (no prefix) |
| Signature scheme | Keyed hash over the raw body (inferred HMAC-SHA256, hex); the SDK computes it |
| Envelope | An array `events: [...]`, up to 250 events per POST |
| Event fields | `resource_type` and `action` are separate fields (not a dotted name) |
| Retries | No automatic schedule documented; manual `POST /webhooks/{id}/actions/retry` |
| Invalid signature | Docs recommend replying `498` |
| SDK | npm `gocardless-nodejs` (verifies); pip `gocardless_pro` |

## Common events

Each element carries `resource_type` and `action` as separate fields (there's no single dotted `payments.confirmed` name). Confirmed pairs:

| resource_type / action | Fires when |
| --- | --- |
| `mandates` / `created`, `cancelled`, `customer_approval_granted` | A mandate is created, cancelled, or approved |
| `payments` / `created`, `submitted`, `confirmed`, `paid_out` | A payment progresses |
| `payments` / `failed`, `charged_back`, `cancelled`, `resubmission_requested` | A payment has trouble |
| `payouts` / `paid` | A payout is paid |

Each element also carries `details{origin, cause, description, scheme, reason_code}` and `links{}`. Dispatch on the `resource_type` + `action` combination.

## Setting up GoCardless webhooks

In the dashboard, go to Developers > Webhook endpoints, add your HTTPS URL, and copy the endpoint secret into `GOCARDLESS_WEBHOOK_SECRET`. Use the durable docs at `docs.gocardless.com` (the legacy `developer.gocardless.com` is deprecating on 24 Aug 2026).

## Securing GoCardless webhooks

In Node, use the official `gocardless-nodejs/webhooks` `parse()`, which verifies the `Webhook-Signature` over the raw body and returns the events array (throwing `InvalidSignatureError` on a mismatch). In other languages, compute a keyed hash over the raw body with your secret and compare in constant time. Verify against the raw body, re-serializing changes the bytes. The docs recommend replying `498` to an invalid signature.

```javascript
const { parse, InvalidSignatureError } = require("gocardless-nodejs/webhooks");

const SECRET = process.env.GOCARDLESS_WEBHOOK_SECRET;

app.post("/webhooks/gocardless", express.raw({ type: "application/json" }), (req, res) => {
  let events;
  try {
    events = parse(req.body, SECRET, req.headers["webhook-signature"]); // raw body
  } catch (err) {
    if (err instanceof InvalidSignatureError) return res.sendStatus(498); // recommended
    return res.sendStatus(400);
  }

  res.sendStatus(204); // acknowledge the whole batch
  for (const event of events) {
    processQueue.add(event); // dedupe on event.id, dispatch on resource_type + action
  }
});

```

The same verification in Python (manual, keyed hash over the raw body). GoCardless doesn't name the algorithm; the observed 64-hex values match HMAC-SHA256:

```python
import hmac
import hashlib
import os

SECRET = os.environ["GOCARDLESS_WEBHOOK_SECRET"].encode()

def verify(raw_body: bytes, signature_header: str) -> bool:
    expected = hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature_header or "")
    # then json.loads(raw_body)["events"] is an array of up to 250 events

```

## GoCardless webhook limitations and pain points

### The body is an array of up to 250 events

The Problem: A single POST carries `events: [...]` with up to 250 events. A handler written for a single-event body silently drops the other 249.

Why It Happens: GoCardless batches events into one delivery.

Workarounds:

* Iterate the `events` array and process each element, then acknowledge once.

How Hookdeck Can Help: Hookdeck can fan a batch out into individual events, so each is delivered, retried, and observed on its own.

### The algorithm isn't documented

The Problem: GoCardless never names the signing algorithm in prose. The values look like HMAC-SHA256, but that's an inference, so a from-scratch implementation is built on an assumption.

Why It Happens: The docs describe the signature only as computed from the body and the endpoint secret.

Workarounds:

* Use the official Node SDK, which computes the correct scheme; treat any manual HMAC-SHA256 as inferred and confirm against a real delivery.

How Hookdeck Can Help: Hookdeck verifies GoCardless deliveries at the edge, so you don't depend on an inferred algorithm.

### There's no automatic retry

The Problem: GoCardless documents no automatic retry schedule. If your endpoint is down, events aren't retried on a timer, you retry them manually.

Why It Happens: GoCardless exposes a manual retry endpoint (`POST /webhooks/{id}/actions/retry`) rather than an automatic backoff.

Workarounds:

* Acknowledge reliably, and reconcile missed events via the API or the manual retry endpoint.

How Hookdeck Can Help: Hookdeck durably queues events and retries on its own schedule, so a downstream outage doesn't require manual recovery.

### Separate fields, and the 498 response

The Problem: Events identify themselves with separate `resource_type` and `action` fields, not a dotted name, and the docs recommend the non-standard `498` status for an invalid signature. Handlers keyed to dotted names, or returning a standard 4xx, get it wrong.

Why It Happens: GoCardless models events as a resource plus an action and adopted `498` for signature failures.

Workarounds:

* Dispatch on `resource_type` + `action`, and reply `498` on a bad signature.

How Hookdeck Can Help: Hookdeck's filters route on the fields you receive, so your app isn't coupled to a naming convention.

## Best practices

### Iterate the events array

Loop over `events` (up to 250) and process each; acknowledge the batch once.

### Verify with the official SDK

Use `gocardless-nodejs/webhooks` `parse()` in Node; treat a manual HMAC-SHA256 as inferred and confirm it against a real delivery.

### Dedupe on event.id and reply 498 on failure

Event creation is asynchronous, so dedupe on `event.id`; reply `498` to an invalid signature.

### Acknowledge fast, process asynchronously

Return 2xx (204) quickly and defer work to a queue. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

GoCardless delivers webhooks as an array of up to 250 events, verified with a `Webhook-Signature` keyed hash over the raw body (an inferred HMAC-SHA256; the Node SDK computes it). Iterate the array, dispatch on the separate `resource_type` and `action` fields, dedupe on `event.id`, reply `498` on a bad signature, and reconcile via the API since there's no automatic retry.

[Hookdeck](https://hookdeck.com) verifies the signature, fans out batches, deduplicates, and durably queues every event at the edge, so your app only ever processes verified, unique events.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle GoCardless webhooks reliably in minutes.