Developer docs
On this page
How it works#
Something happens
For example, a subscriber pays by M-Pesa. CoreSync saves the change first: an event is never sent for a change that didn't stick.
A delivery is queued
One delivery for each of your endpoints that wants that event. The sender picks it up within about 5 seconds.
CoreSync POSTs it
The JSON event goes to your URL with a signature in the headers.
You answer 2xx
The delivery is marked DELIVERED. Anything else is tried again later.
- The same event sent to two endpoints keeps one event
id, and each endpoint gets its own delivery id. - Events can arrive out of order, because a retry can overtake a newer event. Use
occurred_at, and read the record back through the API when order matters.
Add an endpoint#
In the admin: Settings → API access → Webhooks → Add endpoint. Enter the URL, an optional description, and tick the events. When you save, the signing secret is shown once. Copy it.
Through the API: POST /webhooks with the scope webhooks:write and an Idempotency-Key.
curl https://coresyncapi.avitechcloud.com/api/v1/webhooks \
-H "X-API-Key: $CORESYNC_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6f1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d" \
-d '{
"url": "https://hooks.example.com/coresync",
"events": ["payment.received", "subscriber.suspended"],
"description": "Amani Link CRM"
}'{
"id": "2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e",
"url": "https://hooks.example.com/coresync",
"description": "Amani Link CRM",
"events": [
"payment.received",
"subscriber.suspended"
],
"active": true,
"disabled_reason": null,
"last_success_at": "2026-10-25T09:00:06Z",
"last_failure_at": null,
"created_at": "2026-10-25T08:00:00Z",
"signing_secret": "whsec_xxx"
}signing_secret is in this answer only. Store it as a secret: you need it to check every delivery. An ISP can have up to 10 endpoints.
Events#
| Event | Sent when |
|---|---|
payment.received | A payment is received and applied to a subscriber: M-Pesa (STK push, paybill or till), bank, cash, or one recorded through the API. data.payment is the same object GET /payments/{id} returns. |
subscriber.suspended | A subscriber's status becomes SUSPENDED, however it happened: staff, an API key, an automation, or an expired subscription. changed_by is a staff member's name, a key's name, or SYSTEM. |
subscriber.reactivated | A suspended subscriber becomes ACTIVE again: after a payment, by staff, or by an API key. |
session.down | A subscriber's PPPoE session ends. CoreSync checks every 30 seconds, so it arrives within about a minute of the router reporting it. Hotspot sessions are not sent. The event id is evt_sd_ plus the session id, so one session is never sent twice. |
webhook.test | Only when you send a test event. Any endpoint receives it, whatever it subscribes to, and you can't subscribe to it. |
Every event has the same envelope. Only data changes with the type.
| Field | What it is |
|---|---|
id | The event's id. A repeat of the same event keeps it: use it to skip duplicates. |
type | The event type, for example payment.received. |
occurred_at | When it happened, in UTC. |
tenant | Your ISP's code. |
data | What happened. Its shape depends on type. |
Example payloads
{
"id": "evt_6f1d2c3b4a5948e7b6c5d4e3f2a1b0c9",
"type": "payment.received",
"occurred_at": "2026-10-25T09:00:05Z",
"tenant": "AMANI",
"data": {
"payment": {
"id": "1c2d3e4f-5a6b-7c8d-9e0f-1a2b3c4d5e6f",
"subscriber": {
"id": "3f2a6c1e-8b7d-4c1a-9e2f-1a2b3c4d5e6f",
"account_number": "AL-01042",
"name": "Wanjiku Kamau",
"phone": "+254712345678"
},
"amount": {
"amount": "1500.00",
"currency": "KES"
},
"provider": "MPESA",
"method": "PAYBILL",
"reference": "SAMPLE7Q2Z",
"payer_phone": "+254712345678",
"account_reference": "AL1042",
"status": "COMPLETED",
"applied": true,
"applied_at": "2026-10-25T09:00:05Z",
"not_applied_amount": {
"amount": "0.00",
"currency": "KES"
},
"received_at": "2026-10-25T09:00:00Z"
}
}
}Headers#
| Header | What it carries |
|---|---|
X-Webhook-Event | The event type. |
X-Webhook-Delivery-Id | This delivery's id (differs per endpoint and per event). |
X-Webhook-Timestamp | Unix seconds when this attempt was signed. Reject anything older than 5 minutes. |
X-Webhook-Signature | t=<timestamp>,v1=<hex HMAC-SHA256 of "<timestamp>.<raw body>" keyed with the endpoint's signing secret>. |
User-Agent | CoreSync-Webhooks/1.0 |
Content-Type | application/json |
POST /hooks/coresync HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
User-Agent: CoreSync-Webhooks/1.0
X-Webhook-Event: payment.received
X-Webhook-Delivery-Id: 8d9e0f1a-2b3c-4d5e-6f7a-8b9c0d1e2f3a
X-Webhook-Timestamp: 1761382805
X-Webhook-Signature: t=1761382805,v1=5f1c…e9
{"id":"evt_6f1d2c3b4a5948e7b6c5d4e3f2a1b0c9","type":"payment.received", … }Verify the signature#
Check every delivery before you trust it. The header looks like t=1761382805,v1=5f1c…e9.
Keep the raw body
Read the request body as bytes, exactly as it arrived, before any JSON parsing.
Split the header
Split on commas.
tis a Unix time in seconds;v1is the signature in hex.Check the time
Reject it if
tis more than 5 minutes (300 seconds) from your clock. This stops someone replaying an old delivery.Compute the signature
HMAC-SHA256 of
t + "." + raw body, keyed with the whole signing secret (includingwhsec_), written as lowercase hex.Compare in constant time
Compare it with
v1using a constant-time compare. If they differ, answer 400 and drop it.
// Node.js with Express. npm install express
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.CORESYNC_WEBHOOK_SECRET; // whsec_…, shown once when you added the endpoint
const TOLERANCE_SECONDS = 300;
export function verifyCoreSync(rawBody, header, secret = SECRET) {
const parts = {};
for (const piece of (header ?? "").split(",")) {
const i = piece.indexOf("=");
if (i > 0) parts[piece.slice(0, i).trim()] = piece.slice(i + 1).trim();
}
const t = Number(parts.t);
if (!Number.isInteger(t) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false; // too old, or a replay
// HMAC-SHA256 of "<t>.<raw body>", keyed with the whole secret (whsec_ included).
const expected = crypto.createHmac("sha256", secret).update(t + ".").update(rawBody).digest();
const given = Buffer.from(parts.v1, "hex");
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}
const app = express();
// express.raw keeps the exact bytes. express.json() would parse and re-serialise them,
// and the signature would no longer match.
app.post("/hooks/coresync", express.raw({ type: "application/json" }), async (req, res) => {
if (!verifyCoreSync(req.body, req.get("X-Webhook-Signature"))) return res.sendStatus(400);
const event = JSON.parse(req.body.toString("utf8"));
// Save it under event.id (a unique key) and answer. Do the slow work afterwards.
// An id you have already saved is a repeat: answer 200 and skip it.
await saveEventOnce(event.id, event);
res.sendStatus(200);
});
app.listen(3000);Getting the raw body in common frameworks:
| Framework | Raw body |
|---|---|
| Express | express.raw({ type: "application/json" }) |
| Next.js route handler | await request.text() |
| Flask | request.get_data() |
| Django | request.body |
| Laravel | $request->getContent() |
Each retry is signed again with a new timestamp, so the 5-minute check never rejects a real retry.
Answer fast, and expect repeats#
- Answer with any 2xx status within 10 seconds. CoreSync gives up on connecting after 5 seconds.
- Anything else counts as a failure: a 3xx, 4xx or 5xx status, a timeout, or a TLS error.
- Save the event and answer first. Do the slow work, such as sending an SMS or updating your books, afterwards.
- The same event can arrive more than once, for example if you answered just after the timeout. Store event
ids with a unique index and skip one you have already handled.
Retries and backoff#
A delivery that fails is tried again, up to 5 attempts in all:
| Attempt | When |
|---|---|
| 1 | Straight away (within about 5 seconds of the event) |
| 2 | 5 seconds after attempt 1 |
| 3 | 25 seconds after attempt 2 |
| 4 | About 2 minutes after attempt 3 |
| 5 | About 10 minutes after attempt 4 |
After the fifth failure the delivery is marked FAILED and is not sent again. To catch up on what you missed, read the records back through the API, for example GET /payments with from.
20 failed attempts in a row switch an endpoint off. Its disabled_reason says so, and no new events are queued for it while it's off. Events from that time are not sent later. Fix your receiver, then switch it back on in the admin (Edit → Active) or with PATCH /webhooks/{id} and {"active": true}.
Delivery logs#
Every delivery is logged for 30 days: the event, its status (PENDING, DELIVERED or FAILED), the attempts made, the HTTP status your endpoint answered, the last error and the next attempt. See it in the admin with Deliveries, or with GET /webhooks/{id}/deliveries.
{
"items": [
{
"id": "8d9e0f1a-2b3c-4d5e-6f7a-8b9c0d1e2f3a",
"endpoint_id": "2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e",
"event_id": "evt_6f1d2c3b4a5948e7b6c5d4e3f2a1b0c9",
"event_type": "payment.received",
"status": "DELIVERED",
"attempts": 1,
"response_status": 200,
"last_error": null,
"next_attempt_at": null,
"created_at": "2026-10-25T09:00:05Z",
"delivered_at": "2026-10-25T09:00:06Z"
}
],
"total": 137,
"page": 1,
"size": 50
}GET /webhooks/{id} shows an endpoint's health: last_success_at, last_failure_at, active and disabled_reason. Deleting an endpoint deletes its log too.
Send a test event#
Click Send a test event on the endpoint in the admin, or call POST /webhooks/{id}/test. CoreSync sends one signed webhook.test straight away and tells you the HTTP status your endpoint answered. Use it to check your signature code.
- A test is tried once and never retried.
- It works on a switched-off endpoint too.
- It adds one row to the delivery log, and a failed test counts toward the 20-in-a-row switch-off.
Rules for your URL#
- HTTPS only, with a valid certificate. A URL starting
http://is refused. - On the public internet. CoreSync refuses
localhost, names ending.local,.localhostor.internal, and any address that is private, loopback, link-local, carrier-grade NAT (100.64.0.0/10), multicast or reserved, in IPv4 or IPv6. - Checked twice: when you save the URL, and again with a fresh DNS lookup before every attempt. A name that later points at a private address stops receiving.
- No redirects. A 301, 302 or 307 counts as a failure. Point the endpoint at the final URL.
- Up to 500 characters.
A URL or event list that breaks these rules is refused with 422 invalid_webhook, and detail says why. To test on your own computer, expose it through an HTTPS tunnel service.
Rotate the signing secret#
In the admin, click the rotate button on the endpoint (“Issue a new signing secret”). The new secret is shown once, and the old one stops working at once. Update your receiver straight away: deliveries that fail in between are retried, signed with the new secret.
Rotating is only in the admin for now. Changing an endpoint through the API keeps its secret.
Manage endpoints through the API#
GET /webhooks: list webhook endpoints.GET /webhooks/{id}: get a webhook endpoint.GET /webhooks/{id}/deliveries: list an endpoint's deliveries.POST /webhooks: add a webhook endpoint.POST /webhooks/{id}/test: send a test event to an endpoint.PATCH /webhooks/{id}: change a webhook endpoint.DELETE /webhooks/{id}: delete a webhook endpoint.
Stuck, or missing something? Tell us what you're building.
hello@coresyncisp.com