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.

YouCoris
  1. You → Coris
    1POST /webhooks/
  2. Coris → You
    2201 id + signing_secret
  3. An event occurs
  4. Coris → You
    3POST your url, Coris-Signature header
  5. You → Coris
    42xx
  6. Non-2xx or timeout
  7. Coris → You
    5Retry, 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
fi
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.
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 valueSent when
account.tag.created, account.tag.updated, account.tag.deletedA tag on an account changes.
transaction.tag.created, transaction.tag.updated, transaction.tag.deletedA tag on a transaction changes.
alert.created, alert.updated, alert.deletedA merchant alert changes.
transaction.alert.created, transaction.alert.updatedA transaction alert changes.
note.created, note.updated, note.deletedAn 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.

HeaderMeaning
Coris-Signaturet=<unix_ts>,sha256=<hex>
Coris-Api-VersionYour 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"
fi
import 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 True
const 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 ruleValue
Maximum attempts10.
Minimum delay between attempts, in seconds2, 4, 8, 16, 32, 64, 128, 256, 256.
Body on retryThe same payload as the first attempt.
After the last attemptThe 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
fi
events_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
  }
}
FieldMeaningWhat to do
idThe event ID. It stays the same on retries.Use it to deduplicate.
timestampWhen 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.accountThe 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

EventYour action
alert.createdSee Monitor your portfolio.
transaction.alert.createdSee Triage transaction alerts.
Repeated failed deliveriesFix 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


Did this page help you?