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
- Accept
POSTrequests over HTTPS on a publicly reachable URL. See TLS requirements. - Respond with a
2xxstatus within 5 seconds. Store the webhook, respond, then process it. Don't call the Moneyhub API or do other slow work before responding. - Read the request body as raw text. JWT webhooks are not JSON, and the signature is checked against the exact token string.
- 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. - Expect each webhook to be sent once. Webhooks are not retried, so reconcile with the API to catch anything you miss.
- Don't rely on arrival order. Related webhooks, such as
newTransactionsandsyncCompletedfrom the same sync, can arrive in any order. - Make processing idempotent. Skip webhooks whose
idyou have already processed, and base updates on the resource IDs and statuses in the payload so that handling the same change twice is harmless. - Treat webhooks as notifications. Most payloads contain IDs rather than full records. Fetch the latest data from the API using those IDs.
- Ignore event types you don't handle and still respond with
2xx, so that newly added events don't cause errors. - Subscribe only to the events you need using the allowed events setting for your API client.
How delivery works
| Property | Behaviour |
|---|---|
| Method | POST |
| Destination | The webhook URL, or batched webhook URL, set on your API client in the Admin portal |
| Content type | application/json or application/jwt, depending on the format you choose |
| Success | Any 2xx response |
| Timeout | 5 seconds |
| Retries | None. Each webhook is sent once |
| Order | Not guaranteed |
| Source IP addresses | Listed in IP Addresses of Moneyhub Services |
| Delivery history | Shown 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
200straight 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, andpayload, as described in the webhook envelope. - Batched webhooks have
id,userId, and aneventsarray. Each event has anid,eventType, andoccurredAt, but no payload. See batched payloads.
Responding with an error does not trigger a retryBecause 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.
| Events | Suggested action |
|---|---|
newTransactions, updatedTransactions, restoredTransactions | Fetch the transactions by ID and update your copy |
deletedTransactions, deletedAccount | Remove or flag the transactions or account in your system |
syncCompleted | Check processingStatus. On error, check the connection's status and whether the user needs to reconnect |
paymentSubmitted, paymentPending, paymentCompleted, paymentError | Update the payment by paymentId. See handling payment webhooks |
affordabilityReportSuccess, standardFinancialStatementSuccess | Retrieve the report using reportId |
newRegularTransactions, updatedRegularTransactions | Retrieve the regular transaction series |
reauthReminder, refreshReminder | Prompt 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
- Test your receiver locally and with a sandbox client, then work through the production checklist. See Testing and Troubleshooting Webhooks.
- Check the payload of each event you subscribe to in all events.
Updated about 6 hours ago
