Building a Webhook Receiver

How to receive Moneyhub webhooks reliably and securely, with complete Node.js and Python examples.

This guide covers the endpoint in your system that receives Moneyhub webhooks. It is written to be followed step by step, whether you are writing the code yourself or asking an AI coding assistant to write it for you. If you use an assistant, give it this page and the event pages for the events you subscribe to.

Rules for a reliable receiver

  1. Accept POST requests over HTTPS on a publicly reachable URL. See TLS requirements.
  2. Respond with a 2xx status within 5 seconds. Store the webhook, respond, then process it. Don't call the Moneyhub API or do other slow work before responding.
  3. Read the request body as raw text. JWT webhooks are not JSON, and the signature is checked against the exact token string.
  4. Verify JWT webhooks before trusting them. Check the signature, the issuer (https://identity.moneyhub.co.uk), the audience (your client ID), and the token age. See Verifying JWT Webhooks.
  5. Expect each webhook to be sent once. Webhooks are not retried, so reconcile with the API to catch anything you miss.
  6. Don't rely on arrival order. Related webhooks, such as newTransactions and syncCompleted from the same sync, can arrive in any order.
  7. Make processing idempotent. Skip webhooks whose id you have already processed, and base updates on the resource IDs and statuses in the payload so that handling the same change twice is harmless.
  8. Treat webhooks as notifications. Most payloads contain IDs rather than full records. Fetch the latest data from the API using those IDs.
  9. Ignore event types you don't handle and still respond with 2xx, so that newly added events don't cause errors.
  10. Subscribe only to the events you need using the allowed events setting for your API client.

How delivery works

PropertyBehaviour
MethodPOST
DestinationThe webhook URL, or batched webhook URL, set on your API client in the Admin portal
Content typeapplication/json or application/jwt, depending on the format you choose
SuccessAny 2xx response
Timeout5 seconds
RetriesNone. Each webhook is sent once
OrderNot guaranteed
Source IP addressesListed in IP Addresses of Moneyhub Services
Delivery historyShown for each API user in the Admin portal

Step 1: Receive, verify, and store

The examples below are complete receivers. Each one:

  • accepts both JSON and JWT webhooks on one route
  • verifies JWT webhooks and converts them to the same shape as JSON webhooks, so the rest of your code only handles one shape
  • stores the webhook and responds with 200 straight away
  • rejects anything it can't parse or verify with 400

Set the MONEYHUB_CLIENT_ID environment variable to the ID of the API client that receives the webhooks. Replace saveForProcessing with a write to a database table or message queue. The Node.js example needs Node.js 18 or later and uses ES modules, so save it as receiver.mjs or set "type": "module" in your package.json.

// npm install express jose
import express from "express"
import { createRemoteJWKSet, jwtVerify } from "jose"

const CLIENT_ID = process.env.MONEYHUB_CLIENT_ID
const MONEYHUB_ISSUER = "https://identity.moneyhub.co.uk"
const MONEYHUB_JWKS = createRemoteJWKSet(new URL("https://identity.moneyhub.co.uk/oidc/certs"))

const toEventType = (eventName) =>
  eventName.split(":").pop().replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())

async function verifyJwtWebhook(token) {
  const { payload: claims } = await jwtVerify(token, MONEYHUB_JWKS, {
    issuer: MONEYHUB_ISSUER,
    audience: CLIENT_ID,
    algorithms: ["RS256"],
    maxTokenAge: "10m",
    clockTolerance: "30s",
  })

  if (Array.isArray(claims.events)) {
    return { id: claims.jti, userId: claims.sub, events: claims.events }
  }

  const [[eventName, payload]] = Object.entries(claims.events)
  return { id: claims.jti, eventType: toEventType(eventName), userId: claims.sub, payload }
}

async function saveForProcessing(webhook) {
  // Replace with a durable store or queue so processing can happen after you respond.
  console.log("Received Moneyhub webhook", JSON.stringify(webhook))
}

const app = express()

app.post(
  "/webhooks/moneyhub",
  express.text({ type: ["application/json", "application/jwt"] }),
  async (req, res) => {
    let webhook
    try {
      webhook = req.is("application/jwt")
        ? await verifyJwtWebhook(req.body)
        : JSON.parse(req.body)
    } catch (err) {
      console.warn("Rejected Moneyhub webhook:", err.message)
      return res.sendStatus(400)
    }

    await saveForProcessing(webhook)
    res.sendStatus(200)
  },
)

app.listen(process.env.PORT ?? 3000)
# pip install flask "pyjwt[crypto]"
import json
import os
import re
import time

import jwt
from flask import Flask, request

CLIENT_ID = os.environ["MONEYHUB_CLIENT_ID"]
MONEYHUB_ISSUER = "https://identity.moneyhub.co.uk"
MONEYHUB_JWKS = jwt.PyJWKClient("https://identity.moneyhub.co.uk/oidc/certs")
MAX_TOKEN_AGE_SECONDS = 600

app = Flask(__name__)


def to_event_type(event_name):
    kebab = event_name.split(":")[-1]
    return re.sub(r"-([a-z])", lambda match: match.group(1).upper(), kebab)


def verify_jwt_webhook(token):
    signing_key = MONEYHUB_JWKS.get_signing_key_from_jwt(token)
    claims = jwt.decode(
        token,
        signing_key.key,
        algorithms=["RS256"],
        audience=CLIENT_ID,
        issuer=MONEYHUB_ISSUER,
        options={"require": ["iat", "jti", "sub", "events"]},
        leeway=30,
    )
    if time.time() - claims["iat"] > MAX_TOKEN_AGE_SECONDS:
        raise jwt.InvalidTokenError("Webhook token is too old")

    if isinstance(claims["events"], list):
        return {"id": claims["jti"], "userId": claims["sub"], "events": claims["events"]}

    event_name, payload = next(iter(claims["events"].items()))
    return {
        "id": claims["jti"],
        "eventType": to_event_type(event_name),
        "userId": claims["sub"],
        "payload": payload,
    }


def save_for_processing(webhook):
    # Replace with a durable store or queue so processing can happen after you respond.
    print("Received Moneyhub webhook", json.dumps(webhook), flush=True)


@app.post("/webhooks/moneyhub")
def receive_moneyhub_webhook():
    try:
        if request.mimetype == "application/jwt":
            webhook = verify_jwt_webhook(request.get_data(as_text=True))
        else:
            webhook = json.loads(request.get_data())
    except (jwt.PyJWTError, ValueError) as err:
        app.logger.warning("Rejected Moneyhub webhook: %s", err)
        return "", 400

    save_for_processing(webhook)
    return "", 200


if __name__ == "__main__":
    app.run(port=int(os.environ.get("PORT", 3000)))

After this step, every stored webhook has one of two shapes:

  • Immediate webhooks have id, eventType, userId, and payload, as described in the webhook envelope.
  • Batched webhooks have id, userId, and an events array. Each event has an id, eventType, and occurredAt, but no payload. See batched payloads.
📘

Responding with an error does not trigger a retry

Because webhooks are not retried, the status code you return is for your own monitoring. It is shown in the delivery history in the Admin portal.

Step 2: Process webhooks

Process stored webhooks in a background worker. Use eventType to decide what to do, and fetch the current data from the API.

EventsSuggested action
newTransactions, updatedTransactions, restoredTransactionsFetch the transactions by ID and update your copy
deletedTransactions, deletedAccountRemove or flag the transactions or account in your system
syncCompletedCheck processingStatus. On error, check the connection's status and whether the user needs to reconnect
paymentSubmitted, paymentPending, paymentCompleted, paymentErrorUpdate the payment by paymentId. See handling payment webhooks
affordabilityReportSuccess, standardFinancialStatementSuccessRetrieve the report using reportId
newRegularTransactions, updatedRegularTransactionsRetrieve the regular transaction series
reauthReminder, refreshReminderPrompt the user to reconnect the connection in connectionId

Each event page lists the payload fields and explains when the event is sent.

For batched webhooks, the events don't include payloads. Use the event types to decide what to fetch for that user from the API, for example their latest transactions after newTransactions or syncCompleted.

Idempotency

Record the id of every webhook you process and skip any you have already seen. Also design each action so that repeating it is safe. For example, upsert transactions by transaction ID, and only move a payment forward to a final status. This protects you if the same change is ever notified more than once, or your worker processes a stored webhook twice.

Unknown events

New event types can be added. Respond with 2xx to every verified webhook, and log or ignore event types your worker doesn't handle.

Reconcile with the API

Webhooks are sent once and are not retried. If your endpoint is unavailable, slow, or returns an error, that webhook is not sent again. Treat webhooks as a way to learn about changes quickly, and the API as the source of truth.

  • Fetch the data for a user when they first connect, rather than waiting for webhooks.
  • Run a scheduled job that fetches recent transactions for active users, and the status of any payments or reports you are still waiting on.
  • Watch for failed deliveries in the webhook list for each API user in the Admin portal.
  • Alert on errors or slow responses from your webhook endpoint.

Next steps