Get notified when a job finishes
Have Walker Street Data call your server when a job finishes, instead of polling its status.
Before you start
- An API key with the
webhooks:subscribescope to create subscriptions, plus whichever scopes you need to read results (see Authentication). The Node.js, Python and C# snippets read it from theWSD_API_KEYenvironment variable. - The sandbox base URL:
https://sandbox.walkerstdata.com.au. - A publicly reachable HTTPS URL for your receiver. Plain
http://is rejected. While developing, a tunnelling tool can expose a local port over HTTPS. - A
customerId(see Classify a customer's transactions). - The receivers use Express (Node.js 18+, saved as an
.mjsfile), Flask (Python) and an ASP.NET Core minimal API (.NET 8, created withdotnet new web). The other C# snippets are .NET 8 top-level programs usingHttpClientandSystem.Text.Json.
1. Create the subscription
Subscribe to job.completed and give the URL of your receiver. Pass customerIds to hear about specific customers only; an empty list makes a catch-all subscription for every customer. Each subscription covers one event type, so create a second one for job.failed if you want to hear about failures too.
- cURL
- Node.js
- Python
- C#
curl -X POST "https://sandbox.walkerstdata.com.au/v1/webhooks/subscriptions" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"eventType": "job.completed",
"webhookUrl": "https://your-app.example.com/webhooks/wsd",
"customerIds": ["524ba9bc-06e7-417e-bd54-b99785f5194a"]
}'
const BASE_URL = 'https://sandbox.walkerstdata.com.au';
const resp = await fetch(`${BASE_URL}/v1/webhooks/subscriptions`, {
method: 'POST',
headers: {
'x-api-key': process.env.WSD_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
eventType: 'job.completed',
webhookUrl: 'https://your-app.example.com/webhooks/wsd',
customerIds: ['524ba9bc-06e7-417e-bd54-b99785f5194a'],
}),
});
if (!resp.ok) throw new Error(`Create subscription failed: ${resp.status}`);
const { secret } = (await resp.json()).data; // store this securely now
import os
import requests
BASE_URL = "https://sandbox.walkerstdata.com.au"
HEADERS = {"x-api-key": os.environ["WSD_API_KEY"]}
resp = requests.post(
f"{BASE_URL}/v1/webhooks/subscriptions",
headers=HEADERS,
json={
"eventType": "job.completed",
"webhookUrl": "https://your-app.example.com/webhooks/wsd",
"customerIds": ["524ba9bc-06e7-417e-bd54-b99785f5194a"],
},
)
resp.raise_for_status()
secret = resp.json()["data"]["secret"] # store this securely now
using System.Net.Http.Json;
using System.Text.Json.Nodes;
var http = new HttpClient { BaseAddress = new Uri("https://sandbox.walkerstdata.com.au") };
http.DefaultRequestHeaders.Add("x-api-key", Environment.GetEnvironmentVariable("WSD_API_KEY"));
var resp = await http.PostAsJsonAsync("/v1/webhooks/subscriptions", new
{
eventType = "job.completed",
webhookUrl = "https://your-app.example.com/webhooks/wsd",
customerIds = new[] { "524ba9bc-06e7-417e-bd54-b99785f5194a" },
});
resp.EnsureSuccessStatusCode();
var data = (await resp.Content.ReadFromJsonAsync<JsonNode>())!["data"]!;
var secret = (string)data["secret"]!; // store this securely now
A successful create returns 201 Created:
{
"data": {
"webhookSubscriptionId": "e82c5f1a-94d3-4b7e-a6c0-3d19f8b2e754",
"eventType": "job.completed",
"webhookUrl": "https://your-app.example.com/webhooks/wsd",
"secret": "k9Xa2p7Qc1d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0=",
"customerIds": ["524ba9bc-06e7-417e-bd54-b99785f5194a"],
"isCatchAll": false,
"dateCreated": "2026-09-25T04:12:33.517Z"
},
"message": null
}
The secret is only returned when the subscription is created or its secret is rotated. Keep it with your other credentials; the receiver below reads it from WSD_WEBHOOK_SECRET.
2. Run a receiver that verifies each delivery
Every delivery is a POST with a JSON body and an X-Cypher-Webhook-Signature header. The signature is sha256= followed by the lowercase hex HMAC-SHA256 of the raw request body, keyed with your secret decoded from Base64. Verify it over the exact bytes you received (parsing and re-serialising the JSON will break the match), compare in constant time, and reject anything that doesn't match.
Respond with a 2xx within 30 seconds, and do any slow work after acknowledging. Deliveries can arrive more than once, so deduplicate on eventId. The receiver is server code, so there's no cURL version; step 3 tests it with cURL.
- Node.js
- Python
- C#
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = Buffer.from(process.env.WSD_WEBHOOK_SECRET, 'base64');
const seenEventIds = new Set(); // use a durable store in production
function signatureIsValid(rawBody, header) {
const expected =
'sha256=' +
crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex');
const a = Buffer.from(header ?? '');
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// express.raw keeps the body as the exact bytes received.
app.post(
'/webhooks/wsd',
express.raw({ type: 'application/json' }),
(req, res) => {
if (!signatureIsValid(req.body, req.get('X-Cypher-Webhook-Signature'))) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
res.sendStatus(204); // acknowledge first, then process
if (!seenEventIds.has(event.eventId)) {
seenEventIds.add(event.eventId);
handleEvent(event).catch(console.error); // see step 4
}
}
);
async function handleEvent(event) {
console.log(event.eventType, event.jobId);
}
app.listen(8000);
import base64
import hashlib
import hmac
import os
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = base64.b64decode(os.environ["WSD_WEBHOOK_SECRET"])
seen_event_ids = set() # use a durable store in production
def signature_is_valid(raw_body: bytes, header: str) -> bool:
expected = "sha256=" + hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected.encode(), header.encode())
@app.post("/webhooks/wsd")
def wsd_webhook():
raw_body = request.get_data() # the exact bytes, before any JSON parsing
if not signature_is_valid(raw_body, request.headers.get("X-Cypher-Webhook-Signature", "")):
abort(401)
event = request.get_json()
if event["eventId"] not in seen_event_ids:
seen_event_ids.add(event["eventId"])
handle_event(event) # see step 4; hand off to a worker in production
return "", 204
def handle_event(event):
print(event["eventType"], event.get("jobId"))
if __name__ == "__main__":
app.run(port=8000)
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;
var app = WebApplication.Create(args);
var secret = Convert.FromBase64String(Environment.GetEnvironmentVariable("WSD_WEBHOOK_SECRET")!);
var seenEventIds = new ConcurrentDictionary<string, bool>(); // use a durable store in production
bool SignatureIsValid(byte[] rawBody, string header)
{
var expected = "sha256=" + Convert.ToHexString(HMACSHA256.HashData(secret, rawBody)).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(header));
}
app.MapPost("/webhooks/wsd", async (HttpRequest request) =>
{
using var buffer = new MemoryStream();
await request.Body.CopyToAsync(buffer); // the exact bytes, before any JSON parsing
var rawBody = buffer.ToArray();
if (!SignatureIsValid(rawBody, request.Headers["X-Cypher-Webhook-Signature"].ToString()))
{
return Results.Unauthorized();
}
var evt = JsonNode.Parse(rawBody)!;
if (seenEventIds.TryAdd((string)evt["eventId"]!, true))
{
_ = Task.Run(() => HandleEvent(evt)); // acknowledge first, then process (see step 4)
}
return Results.NoContent();
});
Task HandleEvent(JsonNode evt)
{
Console.WriteLine($"{evt["eventType"]} {evt["jobId"]}");
return Task.CompletedTask;
}
app.Run("http://localhost:8000");
A job.completed delivery looks like this. Ignore fields you don't recognise (some internal fields, such as traceparent, may appear and are often null), as new ones can be added.
{
"eventType": "job.completed",
"eventId": "e7c9a1b2-3d4e-5f60-7182-93a4b5c6d7e8",
"clientId": "a0ced7e1-da3a-40ea-a18f-4c829f816c6a",
"customerId": "524ba9bc-06e7-417e-bd54-b99785f5194a",
"timestamp": "2026-09-25T02:15:30Z",
"jobId": "3f9a6c2e-81d4-4b7a-a5e0-c7d219f4b863",
"abn": "53004085616",
"jobType": "Enrichment",
"totalProcessedTransactions": 398,
"totalReceivedTransactions": 412
}
3. Test the receiver locally
Before waiting on a real job, sign a sample body yourself and send it to your receiver. This uses the same algorithm as the platform, so a 204 means your verification is right, and changing a single character of the body should get a 401.
- cURL
- Node.js
- Python
- C#
BODY='{"eventType":"job.completed","eventId":"e7c9a1b2-3d4e-5f60-7182-93a4b5c6d7e8","customerId":"524ba9bc-06e7-417e-bd54-b99785f5194a","jobId":"3f9a6c2e-81d4-4b7a-a5e0-c7d219f4b863"}'
SECRET_HEX=$(printf '%s' "$WSD_WEBHOOK_SECRET" | base64 --decode | xxd -p | tr -d '\n')
SIGNATURE="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$SECRET_HEX" | sed 's/^.*= //')"
curl -i -X POST "http://localhost:8000/webhooks/wsd" \
-H "Content-Type: application/json" \
-H "X-Cypher-Webhook-Signature: $SIGNATURE" \
-d "$BODY"
import crypto from 'node:crypto';
const body =
'{"eventType":"job.completed","eventId":"e7c9a1b2-3d4e-5f60-7182-93a4b5c6d7e8","customerId":"524ba9bc-06e7-417e-bd54-b99785f5194a","jobId":"3f9a6c2e-81d4-4b7a-a5e0-c7d219f4b863"}';
const secret = Buffer.from(process.env.WSD_WEBHOOK_SECRET, 'base64');
const signature =
'sha256=' + crypto.createHmac('sha256', secret).update(body).digest('hex');
const resp = await fetch('http://localhost:8000/webhooks/wsd', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Cypher-Webhook-Signature': signature,
},
body,
});
console.log(resp.status); // 204 when verification works
import base64
import hashlib
import hmac
import os
import requests
body = b'{"eventType":"job.completed","eventId":"e7c9a1b2-3d4e-5f60-7182-93a4b5c6d7e8","customerId":"524ba9bc-06e7-417e-bd54-b99785f5194a","jobId":"3f9a6c2e-81d4-4b7a-a5e0-c7d219f4b863"}'
secret = base64.b64decode(os.environ["WSD_WEBHOOK_SECRET"])
signature = "sha256=" + hmac.new(secret, body, hashlib.sha256).hexdigest()
resp = requests.post(
"http://localhost:8000/webhooks/wsd",
data=body,
headers={"Content-Type": "application/json", "X-Cypher-Webhook-Signature": signature},
)
print(resp.status_code) # 204 when verification works
using System.Security.Cryptography;
using System.Text;
var body = Encoding.UTF8.GetBytes("""{"eventType":"job.completed","eventId":"e7c9a1b2-3d4e-5f60-7182-93a4b5c6d7e8","customerId":"524ba9bc-06e7-417e-bd54-b99785f5194a","jobId":"3f9a6c2e-81d4-4b7a-a5e0-c7d219f4b863"}""");
var secret = Convert.FromBase64String(Environment.GetEnvironmentVariable("WSD_WEBHOOK_SECRET")!);
var signature = "sha256=" + Convert.ToHexString(HMACSHA256.HashData(secret, body)).ToLowerInvariant();
using var http = new HttpClient();
var content = new ByteArrayContent(body);
content.Headers.ContentType = new("application/json");
content.Headers.Add("X-Cypher-Webhook-Signature", signature);
var resp = await http.PostAsync("http://localhost:8000/webhooks/wsd", content);
Console.WriteLine((int)resp.StatusCode); // 204 when verification works
Then submit transactions for the customer (see Classify a customer's transactions) and watch for the real delivery.
4. Fetch the results
job.completed is sent once a job has finished with results, so its status is Completed or CompletedWithErrors. Replace the handleEvent / handle_event / HandleEvent stub from step 2: read the job status for the counts and any warnings, then fetch the job's transactions using the customerId and jobId from the payload.
- cURL
- Node.js
- Python
- C#
curl "https://sandbox.walkerstdata.com.au/v1/jobs/3f9a6c2e-81d4-4b7a-a5e0-c7d219f4b863/status" \
-H "x-api-key: YOUR_API_KEY"
curl "https://sandbox.walkerstdata.com.au/v1/customer/524ba9bc-06e7-417e-bd54-b99785f5194a/transactions?jobId=3f9a6c2e-81d4-4b7a-a5e0-c7d219f4b863&page=1&count=100" \
-H "x-api-key: YOUR_API_KEY"
const BASE_URL = 'https://sandbox.walkerstdata.com.au';
const HEADERS = { 'x-api-key': process.env.WSD_API_KEY };
async function handleEvent(event) {
if (event.eventType !== 'job.completed') return;
const { jobId, customerId } = event;
const job = (
await (
await fetch(`${BASE_URL}/v1/jobs/${jobId}/status`, { headers: HEADERS })
).json()
).data;
const params = new URLSearchParams({ jobId, page: '1', count: '100' });
const txns = (
await (
await fetch(
`${BASE_URL}/v1/customer/${customerId}/transactions?${params}`,
{
headers: HEADERS,
}
)
).json()
).data;
console.log(job.status, txns.totalCount, 'transactions');
}
import requests
BASE_URL = "https://sandbox.walkerstdata.com.au"
HEADERS = {"x-api-key": os.environ["WSD_API_KEY"]}
def handle_event(event):
if event["eventType"] != "job.completed":
return
job_id, customer_id = event["jobId"], event["customerId"]
job = requests.get(f"{BASE_URL}/v1/jobs/{job_id}/status", headers=HEADERS).json()["data"]
txns = requests.get(
f"{BASE_URL}/v1/customer/{customer_id}/transactions",
headers=HEADERS,
params={"jobId": job_id, "page": 1, "count": 100},
).json()["data"]
print(job["status"], txns["totalCount"], "transactions")
// Near the top of Program.cs, alongside the secret
var http = new HttpClient { BaseAddress = new Uri("https://sandbox.walkerstdata.com.au") };
http.DefaultRequestHeaders.Add("x-api-key", Environment.GetEnvironmentVariable("WSD_API_KEY"));
// Replaces the stub from step 2
async Task HandleEvent(JsonNode evt)
{
if ((string?)evt["eventType"] != "job.completed") return;
var jobId = (string)evt["jobId"]!;
var customerId = (string)evt["customerId"]!;
var job = (await http.GetFromJsonAsync<JsonNode>($"/v1/jobs/{jobId}/status"))!["data"]!;
var txns = (await http.GetFromJsonAsync<JsonNode>(
$"/v1/customer/{customerId}/transactions?jobId={jobId}&page=1&count=100"))!["data"]!;
Console.WriteLine($"{job["status"]} {txns["totalCount"]} transactions");
}
Failed deliveries (a non-2xx response, a timeout or an unreachable URL) are not currently retried. Treat webhooks as the fast path and occasionally reconcile outstanding jobs with GET /v1/jobs or the job status endpoint. Report refresh jobs don't send job.completed at all: poll those (see Get a customer's reports and a lending decision).
What's next
- Webhooks: every event type, payload fields, customer scoping and delivery behaviour
- Upload PDF bank statements: the
job.extraction.*events fire for statement uploads - API reference: Create webhook subscription, List webhook subscriptions, Rotate webhook secret, Subscribe customers