Webhooks
Webhooks let Walker Street Data notify your application when an event occurs - for example, when a job finishes enriching - so you don't have to poll for status. We POST a JSON payload to a URL you register, signed so you can verify it came from us.
Events
Subscribe to one event type per subscription:
| Event type | Fires when |
|---|---|
job.completed | A job finishes (status Completed or CompletedWithErrors) |
job.failed | A job fails outright (status Failed) |
job.extraction.completed | The PDF extraction stage of a job finishes |
job.extraction.failed | The PDF extraction stage of a job fails |
job.enrichment.completed | The enrichment stage of a job finishes |
job.enrichment.failed | The enrichment stage of a job fails |
monitoring.rule.triggered | An in-life monitoring rule matches for a customer |
Create a subscription
Register a subscription with the event type you want to receive and the URL we should call. The URL must be an absolute HTTPS URL - plain http:// is rejected with 400. A successful create returns 201 Created.
- cURL
- Node.js
- Python
- C#
curl -X POST "https://api.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": []
}'
const resp = await fetch(
'https://api.walkerstdata.com.au/v1/webhooks/subscriptions',
{
headers: { 'x-api-key': process.env.WSD_API_KEY },
}
);
if (!resp.ok) throw new Error(`Request failed: ${resp.status}`);
const { data } = await resp.json();
import os
import requests
resp = requests.get(
"https://api.walkerstdata.com.au/v1/webhooks/subscriptions",
headers={"x-api-key": os.environ["WSD_API_KEY"]},
)
resp.raise_for_status()
data = resp.json()["data"]
using System.Net.Http.Json;
using System.Text.Json.Nodes;
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", Environment.GetEnvironmentVariable("WSD_API_KEY"));
var resp = await http.GetAsync("https://api.walkerstdata.com.au/v1/webhooks/subscriptions");
resp.EnsureSuccessStatusCode();
var data = (await resp.Content.ReadFromJsonAsync<JsonNode>())!["data"];
The response includes the subscription ID and a signing secret. The secret is a Base64-encoded string and is only returned on create and on rotation - store it securely now, as it can't be retrieved later.
{
"data": {
"webhookSubscriptionId": "b3c1e2d4-5f6a-7890-abcd-ef1234567890",
"eventType": "job.completed",
"webhookUrl": "https://your-app.example.com/webhooks/wsd",
"secret": "k9Xa2p7Qc1d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0=",
"customerIds": [],
"isCatchAll": true,
"dateCreated": "2026-07-02T00:00:00Z"
},
"message": null
}
The webhook payload
We POST a JSON body with Content-Type: application/json. Every payload carries a common envelope:
| Field | Type | Description |
|---|---|---|
eventType | string | The event type (matches your subscription) |
eventId | uuid | Unique ID for this event - use it to deduplicate |
clientId | uuid | Your client ID |
customerId | uuid | The customer the event relates to |
timestamp | string | ISO 8601 time the event was generated |
Payloads can also carry internal fields (such as traceparent, tracestate, and tenantId, often null). Ignore fields you don't recognise - new ones may be added without notice - and don't fail parsing on unknown or null fields.
Job events (job.*) add jobId, abn, and jobType, plus event-specific fields:
| Event type | Additional fields |
|---|---|
job.completed | totalProcessedTransactions, totalReceivedTransactions |
job.extraction.completed | extractedCount, duplicateCount |
job.enrichment.completed | enrichedCount, duplicateCount |
job.failed, job.extraction.failed, job.enrichment.failed | error (nullable) |
monitoring.rule.triggered deliveries instead include ruleset (object with id, slug, and name) and do not include jobId, abn, or jobType.
Example job.completed delivery:
{
"eventType": "job.completed",
"eventId": "e7c9a1b2-3d4e-5f60-7182-93a4b5c6d7e8",
"clientId": "a0ced7e1-da3a-40ea-a18f-4c829f816c6a",
"customerId": "524ba9bc-06e7-417e-bd54-b99785f5194a",
"timestamp": "2026-07-08T02:15:30Z",
"jobId": "34e1bbd4-36be-4d95-9351-a047eed5bf97",
"abn": "12345678901",
"jobType": "Enrichment",
"totalProcessedTransactions": 118,
"totalReceivedTransactions": 120
}
Verifying deliveries
Every delivery includes an X-Cypher-Webhook-Signature header. Verify it before acting on the payload, and reject anything that doesn't match.
The signature is sha256= followed by the lowercase hex HMAC-SHA256 of the raw request body, keyed by your subscription secret decoded from Base64:
X-Cypher-Webhook-Signature: sha256=3b2a1f...<hex>
Compute the expected value over the exact bytes you received (do not re-serialize the JSON - whitespace differences will break the match) and compare in constant time. This is server code, so there's no cURL version; the job webhooks recipe has a complete receiver and a way to test it with cURL.
- Node.js
- Python
- C#
import crypto from 'node:crypto';
function verifyWebhook(rawBody, signatureHeader, secretBase64) {
const key = Buffer.from(secretBase64, 'base64'); // secret is Base64 - decode to raw key bytes
const expected =
'sha256=' + crypto.createHmac('sha256', key).update(rawBody).digest('hex');
const received = Buffer.from(signatureHeader ?? '');
const computed = Buffer.from(expected);
return (
received.length === computed.length &&
crypto.timingSafeEqual(received, computed)
);
}
import base64
import hashlib
import hmac
def verify_webhook(raw_body: bytes, signature_header: str, secret_base64: str) -> bool:
key = base64.b64decode(secret_base64) # secret is Base64 - decode to raw key bytes
expected = "sha256=" + hmac.new(key, raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected.encode(), (signature_header or "").encode())
using System.Security.Cryptography;
using System.Text;
static bool VerifyWebhook(byte[] rawBody, string? signatureHeader, string secretBase64)
{
var key = Convert.FromBase64String(secretBase64); // secret is Base64 - decode to raw key bytes
var expected = "sha256=" + Convert.ToHexString(HMACSHA256.HashData(key, rawBody)).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(signatureHeader ?? ""));
}
If a secret is ever exposed, rotate it. The old secret is invalidated immediately:
- cURL
- Node.js
- Python
- C#
curl -X POST "https://api.walkerstdata.com.au/v1/webhooks/subscriptions/{subscriptionId}/secret/rotate" \
-H "x-api-key: YOUR_API_KEY"
const subscriptionId = 'YOUR_SUBSCRIPTION_ID';
const resp = await fetch(
`https://api.walkerstdata.com.au/v1/webhooks/subscriptions/${subscriptionId}/secret/rotate`,
{
method: 'POST',
headers: { 'x-api-key': process.env.WSD_API_KEY },
}
);
if (!resp.ok) throw new Error(`Request failed: ${resp.status}`);
const { data } = await resp.json();
import os
import requests
subscription_id = "YOUR_SUBSCRIPTION_ID"
resp = requests.post(
f"https://api.walkerstdata.com.au/v1/webhooks/subscriptions/{subscription_id}/secret/rotate",
headers={"x-api-key": os.environ["WSD_API_KEY"]},
)
resp.raise_for_status()
data = resp.json()["data"]
using System.Net.Http.Json;
using System.Text.Json.Nodes;
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", Environment.GetEnvironmentVariable("WSD_API_KEY"));
var subscriptionId = "YOUR_SUBSCRIPTION_ID";
var resp = await http.PostAsync($"https://api.walkerstdata.com.au/v1/webhooks/subscriptions/{subscriptionId}/secret/rotate", null);
resp.EnsureSuccessStatusCode();
var data = (await resp.Content.ReadFromJsonAsync<JsonNode>())!["data"];
Delivery behaviour
- Success is any
2xxresponse from your endpoint. Return quickly (within 30 seconds) - do heavy processing asynchronously after acknowledging. - Failed deliveries (non-
2xx, timeout, or unreachable) are recorded but are not currently retried automatically. Treat webhooks as a fast-path notification and reconcile with a periodic job status poll as a safety net. - Idempotency: deliveries can arrive more than once. Deduplicate on
eventId, and make your handler idempotent.
Scoping to customers
Provide customerIds to scope a subscription to specific customers. Leave the list empty to create a catch-all subscription that receives events for every customer. You can add customers to an existing subscription later:
- cURL
- Node.js
- Python
- C#
curl -X POST "https://api.walkerstdata.com.au/v1/webhooks/subscriptions/{subscriptionId}/customers" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "customerIds": ["524ba9bc-06e7-417e-bd54-b99785f5194a"] }'
const subscriptionId = 'YOUR_SUBSCRIPTION_ID';
const resp = await fetch(
`https://api.walkerstdata.com.au/v1/webhooks/subscriptions/${subscriptionId}/customers`,
{
method: 'POST',
headers: {
'x-api-key': process.env.WSD_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
customerIds: ['524ba9bc-06e7-417e-bd54-b99785f5194a'],
}),
}
);
if (!resp.ok) throw new Error(`Request failed: ${resp.status}`);
const { data } = await resp.json();
import os
import requests
subscription_id = "YOUR_SUBSCRIPTION_ID"
resp = requests.post(
f"https://api.walkerstdata.com.au/v1/webhooks/subscriptions/{subscription_id}/customers",
headers={"x-api-key": os.environ["WSD_API_KEY"]},
json={
"customerIds": ["524ba9bc-06e7-417e-bd54-b99785f5194a"],
},
)
resp.raise_for_status()
data = resp.json()["data"]
using System.Net.Http.Json;
using System.Text.Json.Nodes;
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", Environment.GetEnvironmentVariable("WSD_API_KEY"));
var subscriptionId = "YOUR_SUBSCRIPTION_ID";
var resp = await http.PostAsJsonAsync($"https://api.walkerstdata.com.au/v1/webhooks/subscriptions/{subscriptionId}/customers", new
{
customerIds = new[] { "524ba9bc-06e7-417e-bd54-b99785f5194a" },
});
resp.EnsureSuccessStatusCode();
var data = (await resp.Content.ReadFromJsonAsync<JsonNode>())!["data"];
To remove customers from a subscription, send DELETE to the same path with the customerIds to remove.
A subscription with an empty customer list is a catch-all. If you remove all customers from a scoped subscription, it reverts to receiving events for every customer - it does not go quiet.
Managing subscriptions
List your subscriptions (paginated - see Pagination):
- cURL
- Node.js
- Python
- C#
curl -X GET "https://api.walkerstdata.com.au/v1/webhooks/subscriptions" \
-H "x-api-key: YOUR_API_KEY"
const resp = await fetch(
'https://api.walkerstdata.com.au/v1/webhooks/subscriptions',
{
headers: { 'x-api-key': process.env.WSD_API_KEY },
}
);
if (!resp.ok) throw new Error(`Request failed: ${resp.status}`);
const { data } = await resp.json();
import os
import requests
resp = requests.get(
"https://api.walkerstdata.com.au/v1/webhooks/subscriptions",
headers={"x-api-key": os.environ["WSD_API_KEY"]},
)
resp.raise_for_status()
data = resp.json()["data"]
using System.Net.Http.Json;
using System.Text.Json.Nodes;
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", Environment.GetEnvironmentVariable("WSD_API_KEY"));
var resp = await http.GetAsync("https://api.walkerstdata.com.au/v1/webhooks/subscriptions");
resp.EnsureSuccessStatusCode();
var data = (await resp.Content.ReadFromJsonAsync<JsonNode>())!["data"];
Two things to be aware of:
- Duplicate subscriptions are allowed. Registering the same event type and URL twice creates two independent subscriptions, and your endpoint will receive each event twice (with different signatures). If you're retrying a subscription create that may have succeeded, list your subscriptions first rather than blindly re-creating.
- Delete subscriptions you no longer need with
DELETE /v1/webhooks/subscriptions/{subscriptionId}. Until it's deleted, a subscription keeps delivering - have your endpoint acknowledge (2xx) and discard events you no longer want.