# Guide to Basiq Webhooks: Features and Best Practices

Basiq is an Australian open-banking platform built on the Consumer Data Right (CDR). Apps use it to connect to a customer's bank accounts with consent and pull account and transaction data. Its webhooks tell you when that state changes: a user is created, a bank connection is activated or invalidated, a consent is revoked or about to expire, or new transactions are available. With them you can refresh data, chase expiring consents and react to broken connections without polling the API.

## What are Basiq webhooks?

A Basiq webhook is a subscription that you create through the API, with a URL and the event types it should receive. When one of those events happens, Basiq sends a JSON `POST` that names the event and links to it. The body carries no account or transaction data. Your receiver follows the link with a Basiq API token to read the current state.

Basiq signs each request with three headers, `webhook-id`, `webhook-timestamp` and `webhook-signature`. Its security page describes the Standard Webhooks construction without using the name: an HMAC-SHA256 over the message ID, timestamp and body, keyed with the base64 part of a `whsec_` secret.

## Basiq webhook features

| Feature | Details |
| --- | --- |
| Configuration | API only, `POST /notifications/webhooks` |
| Signature | Standard Webhooks HMAC-SHA256 |
| Signature header | `webhook-signature`, with `webhook-id` and `webhook-timestamp` |
| Secret | One per subscription, `whsec_` plus base64, returned at creation |
| Body | JSON |
| Event routing | Body field `eventTypeId` |
| Payload style | Thin notification with links, no inline data |
| Start-up delay | Up to 15 minutes after a subscription is created |
| Message history | `GET /notifications/webhooks/{webhookId}/messages` |
| Source IPs | `13.238.192.210`, `3.24.252.173`, `3.104.17.39` |
| Retries and timeout | Not documented |

## Events and payloads

Basiq's API reference lists 15 event types, and `GET /events/types` returns the current set:

| Event | Group |
| --- | --- |
| `user.created`, `user.updated`, `user.deleted` | User lifecycle |
| `connection.created`, `connection.activated`, `connection.invalidated`, `connection.deleted` | Bank connection lifecycle |
| `consent.created`, `consent.updated`, `consent.revoked`, `consent.expired`, `consent.reminder`, `consent.warning` | CDR consent lifecycle |
| `account.updated` | Account data changed |
| `transactions.updated` | Transaction data changed |

`consent.reminder` fires when a consent has been active for 90 days since its last update, and `consent.warning` when a consent is about to expire. Names are lower case and dot-separated, and `transactions.updated` is plural where the others are singular.

Every message has the same four fields:

| Field | Meaning |
| --- | --- |
| `eventTypeId` | Event type, for example `user.created`. This is the field to switch on |
| `eventId` | ID of the event |
| `links.event` | URL of the event resource |
| `links.eventEntity` | URL of the affected object (user, connection, consent, account, ...) |

This is the `user.created` sample from the [webhook samples library](https://github.com/hookdeck/webhook-samples/blob/main/providers/basiq/latest/user.created.json):

```json
{
  "eventTypeId": "user.created",
  "eventId": "f2f567b6bbb213c40a2875105327bb37b44a38ab8861b64074e2978b98d1f7c7",
  "links": {
    "event": "https://au-api.basiq.io/events/f2f567b6bbb213c40a2875105327bb37b44a38ab8861b64074e2978b98d1f7c7",
    "eventEntity": "https://au-api.basiq.io/users/88a85f64-5717-4562-b3fc-2c963f66af88"
  }
}

```

## Setting up Basiq webhooks

Basiq has no dashboard form for webhooks. Create the subscription with a server access token:

```bash
curl -X POST https://au-api.basiq.io/notifications/webhooks \
  -H "Authorization: Bearer $BASIQ_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Production receiver",
    "description": "Consent and connection events",
    "url": "https://your-app.com/webhooks/basiq",
    "subscribedEvents": ["user.created", "connection.activated", "consent.revoked", "transactions.updated"]
  }'

```

Only `url` is required. Pick `subscribedEvents` values from `GET /events/types`. The response includes a `secret` of the form `whsec_` followed by base64. Store the whole value, prefix included, as `BASIQ_WEBHOOK_SECRET`. Each subscription has its own secret, and `GET /notifications/webhooks/{webhookId}` looks a subscription up again later.

Expect a delay before the first real event. Basiq says a new subscription can need a cool-off period of up to 15 minutes, and to contact support@basiq.io if nothing arrives after that.

If your endpoint sits behind a firewall, allow `13.238.192.210`, `3.24.252.173` and `3.104.17.39`. Basiq describes these as the complete list of sender addresses.

With Hookdeck, create a source with the **Basiq** type, paste the `whsec_` secret, and subscribe the source URL instead of your own. Your firewall rules then apply to Hookdeck's traffic rather than Basiq's. To test locally, run the [Hookdeck CLI](/docs/cli):

```bash
npx hookdeck-cli listen 3000 basiq --path /webhooks/basiq

```

## Securing Basiq webhooks

The signed content is `<webhook-id>.<webhook-timestamp>.<raw body>`. Strip `whsec_` from the secret, base64-decode the rest, and use those bytes as the HMAC-SHA256 key. `webhook-signature` can hold several space-separated `v1,<base64>` entries; accept the request if any one matches. The function also reads `svix-*` headers when `webhook-id` is absent:

```javascript
const crypto = require('node:crypto');

function decodeBase64(value) {
  if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
  const bytes = Buffer.from(value, 'base64');
  return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
    ? bytes : null;
}

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const prefix = headers['webhook-id'] !== undefined ? 'webhook' : 'svix';
  const id = headers[`${prefix}-id`];
  const timestamp = headers[`${prefix}-timestamp`];
  const signature = headers[`${prefix}-signature`];
  if (typeof id !== 'string' || !id || typeof timestamp !== 'string' ||
      !/^[0-9]+$/.test(timestamp) || typeof signature !== 'string') return false;
  const key = decodeBase64(secret.replace(/^whsec_/, ''));
  if (!key) return false;
  const message = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
  const candidates = signature.split(/\s+/).filter((item) => item.startsWith('v1,')).map((item) => item.slice(3));
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    const received = decodeBase64(candidate);
    if (!received) return false;
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

Basiq's security page notes that its libraries reject timestamps more than five minutes from the current time, and recommends keeping your clock in sync with NTP. That check belongs in your receiver, not in Hookdeck, which applies no timestamp tolerance and resends the original headers when it retries or replays. The function above leaves the timestamp alone so a delayed retry still verifies.

Because the body only links to the data, the receiver's job is small: verify, record the `eventId`, and let a worker fetch `links.eventEntity`.

```javascript
const express = require('express');

// Returns the parsed body when it is a JSON object, otherwise null.
function parseJsonObject(rawBody) {
  try {
    const value = JSON.parse(rawBody.toString('utf8'));
    return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
  } catch {
    return null;
  }
}

// recordEvent(event) saves eventId, eventTypeId and the links, and resolves once stored.
function createBasiqReceiver(recordEvent) {
  const app = express();
  app.post('/webhooks/basiq', express.raw({ type: '*/*' }), async (req, res) => {
    const secret = process.env.BASIQ_WEBHOOK_SECRET;
    if (!secret) return res.sendStatus(500);
    if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);

    const body = parseJsonObject(req.body);
    if (!body) return res.sendStatus(400); // signed, but not a JSON object
    const { eventTypeId, eventId, links } = body;
    try {
      await recordEvent({ eventTypeId, eventId, entityUrl: links?.eventEntity });
    } catch {
      return res.sendStatus(503);
    }
    return res.sendStatus(200);
  });
  return app;
}

```

A worker then calls the entity URL with a Basiq token. For `transactions.updated` it fetches the account's latest transactions; for `consent.revoked` it stops pulling data for that user.

If you use a Hookdeck Basiq source, a request with a bad signature still gets `200` from Hookdeck. Hookdeck records it as failed verification and doesn't forward it.

> **Make Basiq webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies every Basiq consent, connection and transaction notification, stores it, and delivers it to the worker that calls the Basiq API, with retries you control.

## Basiq webhook limitations and pain points

### No documented retry policy

**The Problem:** You can't tell whether a delivery that failed during an outage will come back.

**Why It Happens:** Basiq says a resent message keeps its `webhook-id`, so resends exist, but it doesn't document a retry schedule, how many attempts there are or how long it waits for a response.

**Workarounds:**

* Return `2xx` quickly and do the API calls afterwards.
* After any outage, list `GET /notifications/webhooks/{webhookId}/messages` and process anything you missed.

**How Hookdeck Can Help:** Hookdeck accepts Basiq's request immediately and stores it, then [retries delivery](/docs/retries) to your worker on a schedule you set, so your outage doesn't depend on Basiq's unpublished policy.

### Every event needs an API call

**The Problem:** A burst of `transactions.updated` events turns into a burst of API requests from your side.

**Why It Happens:** Messages carry only `eventTypeId`, `eventId` and two links. To find out what changed, you have to call Basiq.

**Workarounds:**

* Collapse events per entity before fetching: several `transactions.updated` events for one account need only one fetch.
* Fetch from a worker with its own concurrency limit rather than from the request handler.

**How Hookdeck Can Help:** A Hookdeck destination's [max delivery rate](/docs/destinations#set-a-max-delivery-rate) smooths a burst of notifications so your worker calls the Basiq API at a steady pace.

### The first events are delayed

**The Problem:** A freshly created subscription appears to do nothing.

**Why It Happens:** Basiq can take up to 15 minutes to start delivering after you subscribe.

**Workarounds:**

* Wait out the cool-off before debugging, then use the message history endpoint to see what Basiq has sent.

**How Hookdeck Can Help:** Once deliveries start, Hookdeck's request log shows each message with its headers, so you can confirm arrivals without adding logging to your own service.

### A five-minute clock in Basiq's libraries

**The Problem:** Retried or replayed events fail verification in a receiver built on Basiq's libraries.

**Why It Happens:** The libraries reject any timestamp more than five minutes from the current time, and a resend carries its original `webhook-timestamp`.

**Workarounds:**

* Verify with the function above, or widen the library's tolerance beyond your longest retry delay.
* Deduplicate on `webhook-id` instead of relying on the timestamp for replay protection.

**How Hookdeck Can Help:** Hookdeck verifies the signature without any timestamp tolerance, so a late retry still passes at the source.

## Best practices

### Follow the link for current state

Treat each message as a hint that something changed, not as the change itself. Fetching `links.eventEntity` always gives you the latest version, so a duplicated or out-of-order event does no harm.

### Deduplicate on `webhook-id`

`webhook-id` stays the same when Basiq resends a message. Store it, or the body's `eventId`, before processing. Hookdeck's [idempotency guide](/webhooks/guides/implement-webhook-idempotency) covers the details.

### Act on consent events quickly

`consent.revoked` and `consent.expired` mean you may no longer use that customer's data. `consent.warning` and `consent.reminder` are the cue to ask the customer to renew before access lapses.

### Test with Basiq's sample messages

`POST /notifications/messages/test` sends a test request with sample data for the event types you choose. Use it to check verification and routing for each event family before relying on real consent and transaction activity.

## Conclusion

Basiq's webhooks are link-only notifications, so the receiver's real job is fetching the linked entity. Build that fetch to cope with a retry policy Basiq doesn't document.

To keep those notifications safe while your worker or the Basiq API is unavailable, put [Hookdeck Event Gateway](https://hookdeck.com) in front of your endpoint. [Start with a free Hookdeck account](https://dashboard.hookdeck.com/signup).