Receive webhooks
Register a Coris webhook endpoint, choose event types, verify the Coris-Signature header and handle retries.
Coris sends signed events when tags, alerts and notes change. Register an HTTPS endpoint, choose your events, and verify each delivery with your signing secret.
- You → Coris1POST
/ webhooks/ - Coris → You2201
id + signing_secret - An event occurs
- Coris → You3POST your url, Coris-Signature header
- You → Coris42xx
- Non-2xx or timeout
- Coris → You5Retry, up to 10 attempts
Step 1: Register an endpoint
Register a public HTTPS URL to get a signing_secret. While you test, a tunnel to your machine gives you one. Create a webhook endpoint lists the required fields.
export CORIS_BASE_URL="https://sandbox-api.coris.ai/api/v0_2"
export CORIS_API_KEY="YOUR_API_KEY"import requests
BASE_URL = "https://sandbox-api.coris.ai/api/v0_2"
HEADERS = {"Authorization": "Token YOUR_API_KEY"}const BASE_URL = "https://sandbox-api.coris.ai/api/v0_2";
const HEADERS = {
Authorization: "Token YOUR_API_KEY",
"Content-Type": "application/json",
};WEBHOOK_URL="https://hooks.acmeplatform.example/coris"
SUBSCRIBED='["account.tag.created", "alert.created"]'
# Reuse the endpoint if this URL is already registered. A second POST returns 500.
WEBHOOK=$(curl -s "$CORIS_BASE_URL/webhooks/?disabled=true&limit=100" \
-H "Authorization: Token $CORIS_API_KEY" \
| jq -c --arg url "$WEBHOOK_URL" '.[] | select(.url == $url)')
if [ -z "$WEBHOOK" ]; then
WEBHOOK=$(curl -s -X POST "$CORIS_BASE_URL/webhooks/" \
-H "Authorization: Token $CORIS_API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"Risk events\",
\"description\": \"Sends risk events to the review service\",
\"url\": \"$WEBHOOK_URL\",
\"subscribed_events\": $SUBSCRIBED
}")
fi
WEBHOOK_ID=$(printf '%s' "$WEBHOOK" | jq -r '.id')
SIGNING_SECRET=$(printf '%s' "$WEBHOOK" | jq -r '.signing_secret')
# A reused endpoint may be disabled, or subscribed to other events.
if [ "$(printf '%s' "$WEBHOOK" | jq '.enabled')" = "false" ]; then
curl -s -X POST "$CORIS_BASE_URL/webhooks/$WEBHOOK_ID/enable/" \
-H "Authorization: Token $CORIS_API_KEY" > /dev/null
fi
if [ "$(printf '%s' "$WEBHOOK" | jq --argjson want "$SUBSCRIBED" '(.subscribed_events | sort) == ($want | sort)')" = "false" ]; then
printf '%s' "$WEBHOOK" | jq --argjson want "$SUBSCRIBED" '{name, description, subscribed_events: $want}' \
| curl -s -X POST "$CORIS_BASE_URL/webhooks/$WEBHOOK_ID/" \
-H "Authorization: Token $CORIS_API_KEY" \
-H "Content-Type: application/json" \
-d @- > /dev/null
fiwebhook_url = "https://hooks.acmeplatform.example/coris"
subscribed = ["account.tag.created", "alert.created"]
# Reuse the endpoint if this URL is already registered. A second POST returns 500.
existing = requests.get(
f"{BASE_URL}/webhooks/", params={"disabled": "true", "limit": 100}, headers=HEADERS
).json()
webhook = next((item for item in existing if item["url"] == webhook_url), None)
if webhook is None:
response = requests.post(
f"{BASE_URL}/webhooks/",
headers=HEADERS,
json={
"name": "Risk events",
"description": "Sends risk events to the review service",
"url": webhook_url,
"subscribed_events": subscribed,
},
)
response.raise_for_status()
webhook = response.json()
webhook_id = webhook["id"]
signing_secret = webhook["signing_secret"]
# A reused endpoint may be disabled, or subscribed to other events.
if not webhook["enabled"]:
requests.post(f"{BASE_URL}/webhooks/{webhook_id}/enable/", headers=HEADERS).raise_for_status()
if sorted(webhook["subscribed_events"]) != sorted(subscribed):
requests.post(
f"{BASE_URL}/webhooks/{webhook_id}/",
headers=HEADERS,
json={
"name": webhook["name"],
"description": webhook["description"],
"subscribed_events": subscribed,
},
).raise_for_status()const webhookUrl = "https://hooks.acmeplatform.example/coris";
const subscribed = ["account.tag.created", "alert.created"];
// Reuse the endpoint if this URL is already registered. A second POST returns 500.
const listed = await fetch(`${BASE_URL}/webhooks/?disabled=true&limit=100`, { headers: HEADERS });
let webhook = (await listed.json()).find((item) => item.url === webhookUrl);
if (!webhook) {
const createdWebhook = await fetch(`${BASE_URL}/webhooks/`, {
method: "POST",
headers: HEADERS,
body: JSON.stringify({
name: "Risk events",
description: "Sends risk events to the review service",
url: webhookUrl,
subscribed_events: subscribed,
}),
});
if (!createdWebhook.ok) throw new Error(await createdWebhook.text());
webhook = await createdWebhook.json();
}
const webhookId = webhook.id;
const signingSecret = webhook.signing_secret;
// A reused endpoint may be disabled, or subscribed to other events.
if (!webhook.enabled) {
await fetch(`${BASE_URL}/webhooks/${webhookId}/enable/`, { method: "POST", headers: HEADERS });
}
if ([...webhook.subscribed_events].sort().join() !== [...subscribed].sort().join()) {
await fetch(`${BASE_URL}/webhooks/${webhookId}/`, {
method: "POST",
headers: HEADERS,
body: JSON.stringify({
name: webhook.name,
description: webhook.description,
subscribed_events: subscribed,
}),
});
}Try POST /webhooks/ in the API explorer.
Store signing_secret in your secret manager.
Each URL can be registered once. A second POST returns 500, so this sample lists enabled and disabled endpoints first and reuses a match. A 403 here means the key's user lacks organization write permission.
A deleted endpoint is not listed, even with disabled=true, and its URL stays taken. To pause delivery, disable the endpoint instead of deleting it. After a delete, register a new URL.
subscribed_events value | Sent when |
|---|---|
account.tag.created, account.tag.updated, account.tag.deleted | A tag on an account changes. |
transaction.tag.created, transaction.tag.updated, transaction.tag.deleted | A tag on a transaction changes. |
alert.created, alert.updated, alert.deleted | A merchant alert changes. |
transaction.alert.created, transaction.alert.updated | A transaction alert changes. |
note.created, note.updated, note.deleted | An account note changes. |
Tag, note and alert *.deleted payloads add deleted_at in epoch seconds.
Step 2: Verify the signature
Verify each delivery before you act on it.
| Header | Meaning |
|---|---|
Coris-Signature | t=<unix_ts>,sha256=<hex> |
Coris-Api-Version | Your workspace API version, such as 2024-08-23. |
Coris signs {t}.{raw_body} with HMAC-SHA256. Use the exact bytes you received, because parsing and re-serializing changes the signature. Delivery logs hold the body as parsed JSON, so they cannot verify a signature.
# HEADER holds Coris-Signature. BODY holds the raw request body.
T=$(echo "$HEADER" | sed -E 's/.*t=([0-9]+).*/\1/')
PROVIDED=$(echo "$HEADER" | sed -E 's/.*sha256=([0-9a-f]+).*/\1/')
EXPECTED=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SIGNING_SECRET" -hex | awk '{print $NF}')
[ "$EXPECTED" = "$PROVIDED" ] && echo "valid" || echo "invalid"import hashlib
import hmac
import time
def verify_webhook(header: str, raw_body: bytes, secret: str, tolerance: int = 300) -> bool:
parts = dict(item.split("=", 1) for item in header.split(","))
timestamp, provided = parts["t"], parts["sha256"]
if abs(time.time() - int(timestamp)) > tolerance:
return False
message = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, provided)import crypto from "node:crypto";
function verifyWebhook(header, rawBody, secret, tolerance = 300) {
const parts = Object.fromEntries(header.split(",").map((item) => item.split("=")));
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (age > tolerance) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`, "utf8")
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.sha256);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Step 3: Respond and deduplicate
Return any 2xx within 10 seconds, then do slow work in a queue. Store each event id, and skip one you have already handled.
# BODY holds the raw request body. SEEN_FILE lists the event IDs you have handled.
SEEN_FILE="${SEEN_FILE:-/tmp/coris_seen_events}"
touch "$SEEN_FILE"
EVENT_ID=$(printf '%s' "$BODY" | jq -r '.id')
if grep -qxF "$EVENT_ID" "$SEEN_FILE"; then
echo "duplicate"
else
echo "$EVENT_ID" >> "$SEEN_FILE"
echo "new event"
fiimport json
seen_event_ids = set() # Use a durable store in production.
def is_new_event(raw_body: bytes) -> bool:
event_id = json.loads(raw_body)["id"]
if event_id in seen_event_ids:
return False
seen_event_ids.add(event_id)
return Trueconst seenEventIds = new Set(); // Use a durable store in production.
function isNewEvent(rawBody) {
const eventId = JSON.parse(rawBody).id;
if (seenEventIds.has(eventId)) return false;
seenEventIds.add(eventId);
return true;
}| Delivery rule | Value |
|---|---|
| Maximum attempts | 10. |
| Minimum delay between attempts, in seconds | 2, 4, 8, 16, 32, 64, 128, 256, 256. |
| Body on retry | The same payload as the first attempt. |
| After the last attempt | The delivery is marked failed and is not retried. |
Step 4: Inspect deliveries
List events, then read the attempts for one event. The list order is not guaranteed, so this sample reads every page and sorts on event_timestamp to find the latest event.
EVENTS_FILE="${EVENTS_FILE:-/tmp/coris_events}"
OFFSET=0
: > "$EVENTS_FILE"
while : ; do
PAGE=$(curl -s "$CORIS_BASE_URL/webhooks/$WEBHOOK_ID/events/?limit=100&offset=$OFFSET" \
-H "Authorization: Token $CORIS_API_KEY")
printf '%s' "$PAGE" | jq -c '.[]' >> "$EVENTS_FILE"
[ "$(printf '%s' "$PAGE" | jq 'length')" -lt 100 ] && break
OFFSET=$((OFFSET + 100))
done
EVENT_ID=$(jq -rs 'sort_by(.event_timestamp) | last | .id // empty' "$EVENTS_FILE")
if [ -n "$EVENT_ID" ]; then
curl -s "$CORIS_BASE_URL/webhooks/events/$EVENT_ID/deliveries/" \
-H "Authorization: Token $CORIS_API_KEY" | jq
fievents_url = f"{BASE_URL}/webhooks/{webhook_id}/events/"
events, offset = [], 0
while True:
page = requests.get(
events_url, params={"limit": 100, "offset": offset}, headers=HEADERS
).json()
events.extend(page)
if len(page) < 100:
break
offset += 100
if events:
latest = max(events, key=lambda event: event["event_timestamp"])
deliveries = requests.get(
f"{BASE_URL}/webhooks/events/{latest['id']}/deliveries/", headers=HEADERS
).json()
print(deliveries)const eventsUrl = `${BASE_URL}/webhooks/${webhookId}/events/`;
const events = [];
for (let offset = 0; ; offset += 100) {
const listed = await fetch(`${eventsUrl}?limit=100&offset=${offset}`, { headers: HEADERS });
const page = await listed.json();
events.push(...page);
if (page.length < 100) break;
}
if (events.length > 0) {
const latest = events.reduce((a, b) => (b.event_timestamp > a.event_timestamp ? b : a));
const deliveries = await fetch(`${BASE_URL}/webhooks/events/${latest.id}/deliveries/`, {
headers: HEADERS,
});
console.log(await deliveries.json());
}Try GET /webhooks/{webhook_id}/events/ in the API explorer.
List webhook events returns all events in your workspace, not only those sent to this endpoint.
Each attempt shows the response code and any error, so you can check delivery before your receiver is ready.
Test webhooks in the sandbox
The public API has no test trigger for alerts, so test your handler with tag events. They use the same envelope and signature.
Subscribe to account.tag.created, apply a new tag with Set an account tag, and check the delivery as in step 4. Setting a tag the account already holds sends account.tag.updated instead.
Read the response
Every event has the same envelope. This is account.tag.created.
{
"id": "5f3d2a9b-1c4e-4a72-8e8a-9f07a2a0b123",
"type": "account.tag.created",
"timestamp": 1762300800,
"data": {
"id": "2c9e4d9a-5f8a-4e2b-9c11-3bd6c0c0d0a1",
"source": "API",
"account_tag_name": "manual_review",
"account_tag_value": "true",
"entity_type": "Account",
"entity_uuid": "c47f0e21-6a9b-4d38-a2e5-1b8d3f90c6a7",
"author": "[email protected]",
"account": {
"id": "c47f0e21-6a9b-4d38-a2e5-1b8d3f90c6a7",
"reference_id": "acme-plumbing-001",
"name": "Acme Plumbing Co.",
"status": "active"
},
"created": 1762300800,
"updated": 1762300800
}
}| Field | Meaning | What to do |
|---|---|---|
id | The event ID. It stays the same on retries. | Use it to deduplicate. |
timestamp | When the event occurred, in epoch seconds. List webhook events returns it as event_timestamp. | Use it to order events. Delivery order is not guaranteed. |
data.account | The full account object, shortened here. | Use reference_id to find your merchant. |
Alert events use the same envelope and data.account. On a Stripe alert, data.transaction_id is the Coris ID of the payment. It is not the id of a pushed transaction.
Alert payloads hold more fields. The partner reference lists them, and you can request it.
What to do next
| Event | Your action |
|---|---|
alert.created | See Monitor your portfolio. |
transaction.alert.created | See Triage transaction alerts. |
| Repeated failed deliveries | Fix your endpoint, then read what you missed from List webhook events. |
FAQ
Can I replay a failed delivery?
No. Read missed events from List webhook events.
How do I pause deliveries?
Use Disable a webhook endpoint. Events raised while it is disabled get no delivery, so read them from List webhook events. Enable a webhook endpoint resumes deliveries that were already queued.
Are webhooks and callbacks the same?
Callbacks answer one async request and use a workspace secret. Webhooks report ongoing events and use a secret per endpoint.
Next: Handle errors
Updated about 23 hours ago