Gareth Wilson Gareth Wilson

Guide to Basiq Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationAPI only, POST /notifications/webhooks
SignatureStandard Webhooks HMAC-SHA256
Signature headerwebhook-signature, with webhook-id and webhook-timestamp
SecretOne per subscription, whsec_ plus base64, returned at creation
BodyJSON
Event routingBody field eventTypeId
Payload styleThin notification with links, no inline data
Start-up delayUp to 15 minutes after a subscription is created
Message historyGET /notifications/webhooks/{webhookId}/messages
Source IPs13.238.192.210, 3.24.252.173, 3.104.17.39
Retries and timeoutNot documented

Events and payloads

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

EventGroup
user.created, user.updated, user.deletedUser lifecycle
connection.created, connection.activated, connection.invalidated, connection.deletedBank connection lifecycle
consent.created, consent.updated, consent.revoked, consent.expired, consent.reminder, consent.warningCDR consent lifecycle
account.updatedAccount data changed
transactions.updatedTransaction 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:

FieldMeaning
eventTypeIdEvent type, for example user.created. This is the field to switch on
eventIdID of the event
links.eventURL of the event resource
links.eventEntityURL of the affected object (user, connection, consent, account, ...)

This is the user.created sample from the webhook samples library:

{
  "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:

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:

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:

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.

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

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 covers the details.

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 in front of your endpoint. Start with a free Hookdeck account.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.