Skip to content

Webhooks and signatures

A webhook tells your own server the moment an invoice is paid, a service goes live or a ticket is opened.

  1. In your admin, go to Settings, then API and webhooks.
  2. Under Webhooks, choose Add an endpoint.
  3. Under Send to, type an https address on your own server, such as https://example.com/hooks/billing.
  4. Under When, tick the events you want: Invoice paid, Service active or Ticket opened.
  5. Type the Code from your authenticator app and choose Add the endpoint.
  6. Copy the Signing secret. It is shown this once. It starts with whsec_.

The address must use https on port 443 and a public DNS name. An IP address, localhost or a private address is refused. Nothing is sent when you add it.

Event Sent when data holds
invoice.paid An invoice is paid in full invoice (id, number, customerId, total, paidAt) and paymentId
service.activated A service becomes active, when first set up or restored service (id, customerId, productId) and previousStatus
ticket.opened A ticket is opened by the customer or by staff for them ticket (id, number, customerId)

Every body has id, type, createdAt and data. Payloads carry ids and figures only, never a secret, a phone number or the words of a message.

{
"id": "evt_01J9ZK4Q2W",
"type": "invoice.paid",
"createdAt": "2026-11-04T09:12:00Z",
"data": {
"invoice": {
"id": "INVOICE_ID",
"number": "INV-2027-0207",
"customerId": "CUSTOMER_ID",
"total": { "amountMinor": 98000, "currency": "RWF" },
"paidAt": "2026-11-04T09:11:58Z"
},
"paymentId": "PAYMENT_ID"
}
}

Deliveries are signed the Standard Webhooks way (standardwebhooks.com), so you can use its libraries. Each POST carries three headers:

  • webhook-id: the event id. It is the same on every try.
  • webhook-timestamp: seconds since 1970. It is new on every try.
  • webhook-signature: v1, and a base64 HMAC-SHA256.

To check a delivery:

  1. Take the secret after whsec_ and decode it from base64. Those bytes are the key.
  2. Build the text {webhook-id}.{webhook-timestamp}.{raw body}. Use the raw body exactly as received.
  3. Work out HMAC-SHA256 of that text with the key, and base64 it.
  4. Compare it, in constant time, with each signature in webhook-signature after v1,. The header can hold more than one, separated by a space.
  5. Refuse a timestamp more than five minutes from your own clock.
  6. Drop an event id you have already handled.
import { createHmac, timingSafeEqual } from "node:crypto";
function isFromTaji(headers, rawBody, secret) {
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const text = `${headers["webhook-id"]}.${headers["webhook-timestamp"]}.${rawBody}`;
const expected = Buffer.from(createHmac("sha256", key).update(text).digest("base64"));
return headers["webhook-signature"].split(" ").some((part) => {
const given = Buffer.from(part.replace(/^v1,/, ""));
return given.length === expected.length && timingSafeEqual(given, expected);
});
}

Answer with any 2xx within 15 seconds. A redirect counts as a failure and is never followed.

If the answer is not 2xx, or does not come, we try again. The tries come after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours. A last try comes 24 hours after the first. Then the delivery has failed, the endpoint is paused and the account owner gets an email.

Each event is sent once per endpoint, and every try has the same body and webhook-id. Events may arrive out of order.

  • Open the endpoint’s Deliveries log to see each delivery’s time, event and answer. Choose Send again to retry one.
  • A paused endpoint is sent nothing. Choose Resume to start again from new events.
  • Choose Rotate the secret for a new signing secret. The old one keeps signing too, for 24 hours, so each try carries two signatures while you change over.
  • Choose Remove the endpoint to stop all deliveries.

Contact Taji support at help@gettaji.com or +250782009639.