Webhooks
Receive real-time notifications when events happen in your Yona account.
Setting up a webhook
Save the secret from the response — you need it to verify signatures.
Available events
These are the events you can subscribe to:
Invoice events
| Event | Description |
|---|---|
invoice.created | Invoice created (draft status) |
invoice.updated | Draft invoice modified |
invoice.submitted | Invoice queued for submission to tax authority |
invoice.approved | Tax authority accepted the invoice |
invoice.rejected | Tax authority rejected the invoice |
invoice.submission_failed | Submission failed before tax authority response |
invoice.cancelled | Invoice cancelled (credit note created) |
invoice.status.changed | Invoice status changed via authority callback |
invoice.retry_scheduled | Failed invoice re-queued for retry |
Seller & Buyer events
| Event | Description |
|---|---|
seller.created | New seller registered |
seller.updated | Seller details changed |
seller.tax_number_verified | Seller TIN verification succeeded |
seller.tax_number_verification_failed | Seller TIN verification failed |
buyer.created | New buyer registered |
buyer.updated | Buyer details changed |
buyer.deleted | Buyer soft-deleted |
buyer.tax_number_verified | Buyer TIN verification succeeded |
buyer.tax_number_verification_failed | Buyer TIN verification failed |
User events
| Event | Description |
|---|---|
user.created | User signed up or accepted invitation |
user.updated | User profile changed |
user.deleted | User soft-deleted |
user.role_changed | User role reassigned |
user.status_changed | User status changed (active/suspended/locked) |
Organization events
| Event | Description |
|---|---|
organization.created | Organization created |
organization.updated | Organization metadata changed |
organization.status_changed | Organization status changed |
organization.tax_number_verified | Organization TIN verified |
organization.onboarding_completed | Onboarding workflow completed |
organization.subscription_tier_changed | Subscription tier changed |
API key events
| Event | Description |
|---|---|
api_key.created | New API key generated |
api_key.revoked | API key revoked |
api_key.rotated | API key rotated (new key issued) |
api_key.permissions_changed | API key permissions updated |
Payment & billing events
| Event | Description |
|---|---|
payment.created | Payment initiated |
payment.succeeded | Payment completed |
payment.failed | Payment declined or failed |
billing_account.status_changed | Billing account suspended or reactivated |
billing_account.low_balance | Credit balance dropped below threshold |
Subscription & credit events
| Event | Description |
|---|---|
subscription.created | New subscription created |
subscription.renewed | Subscription renewed |
subscription.cancelled | Subscription cancelled |
subscription.expired | Subscription expired (unpaid) |
subscription.plan_changed | Plan upgrade/downgrade applied |
subscription.payment_failed | Renewal payment declined |
subscription.payment_failed_final | All renewal retries exhausted |
credit.balance_updated | Credit balance changed (any mutation) |
credit.purchased | Credits purchased |
Collection events
For the Collection API — collecting invoice payments from buyers via virtual accounts.
| Event | Description |
|---|---|
collection.payment_received | A buyer transfer landed on a collection account |
collection.payment_reconciled | A received payment was matched to an invoice |
collection.payment_applied | A payment was applied to invoice(s); carries appliedAmount, unappliedAmount, invoiceIds, and the outcome status (applied/overpaid/unmatched) |
collection.settlement_updated | A seller’s settlement account / collection readiness changed |
Meta events
| Event | Description |
|---|---|
webhook.test | Test event for endpoint verification |
* | Wildcard — subscribe to all events |
Payload shape
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"type": "invoice.approved",
"environment": "sandbox",
"timestamp": "2026-04-30T11:10:05.000Z",
"data": {
"invoiceId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"invoiceNumber": "INV-2026-001",
"status": "accepted",
"jurisdiction": "NG",
"authorityReference": "NRS-REF-001",
"submittedAt": "2026-04-30T11:10:05.000Z"
}
}Signature verification
Every webhook delivery includes these headers:
| Header | Description |
|---|---|
X-Webhook-Signature | HMAC-SHA256 signature: sha256=<hex> |
X-Webhook-Timestamp | Unix timestamp (seconds) |
X-Webhook-Event | Event type string |
X-Webhook-ID | Event ID (UUID) |
X-Webhook-Delivery-Attempt | Attempt number (starts at 1) |
Always verify the signature before processing.
import express from 'express';
import { verifyWebhook } from '@useyona/einvoice-js/webhooks';
const app = express();
app.post('/webhooks/yona', express.json(), (req, res) => {
try {
const event = verifyWebhook(
req.body,
req.headers,
process.env.WEBHOOK_SECRET!,
);
switch (event.type) {
case 'invoice.approved':
handleApproved(event.data);
break;
case 'invoice.rejected':
handleRejected(event.data);
break;
case 'invoice.submission_failed':
handleFailed(event.data);
break;
}
res.status(200).json({ received: true });
} catch (err) {
res.status(400).json({ error: 'Invalid signature' });
}
});You can pass either the raw body (Buffer/string) or JSON.stringify(req.body) — the SDK handles both. Using express.json() or express.raw() are both fine.
Manual verification
If you prefer not to use the SDK, you can verify signatures manually. The backend signs only the data field — not the entire body.
import express from 'express';
import crypto from 'crypto';
const app = express();
app.post('/webhooks/yona', express.json(), (req, res) => {
const signature = req.headers['x-webhook-signature'] as string;
const timestamp = req.headers['x-webhook-timestamp'] as string;
if (!signature || !timestamp) {
return res.status(400).json({ error: 'Missing signature headers' });
}
// Replay protection: reject timestamps older than 5 minutes
const age = Math.abs(Math.floor(Date.now() / 1000) - parseInt(timestamp));
if (age > 300) {
return res.status(400).json({ error: 'Timestamp too old' });
}
// Backend signs only the `data` field
const signedPayload = `${timestamp}.${JSON.stringify(req.body.data)}`;
const expected = `sha256=${crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET!)
.update(signedPayload)
.digest('hex')}`;
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return res.status(400).json({ error: 'Invalid signature' });
}
// Signature valid — process event
const { type, data, environment } = req.body;
switch (type) {
case 'invoice.approved':
handleApproved(data);
break;
case 'invoice.rejected':
handleRejected(data);
break;
case 'invoice.submission_failed':
handleFailed(data);
break;
}
res.status(200).json({ received: true });
});Retry logic
- 3 total attempts (1 initial + 2 retries)
- Backoff: exponential (1s initial delay, 2x multiplier)
- Success = HTTP 2xx response
- Timeout = 10 seconds per delivery attempt
- Endpoints can override with a custom
retryConfig - After all attempts fail, the delivery is marked as
failed
You can manually retry a failed delivery:
Testing webhooks
Send a test event to verify your endpoint:
In sandbox mode, all webhook events fire normally. You can use tools like webhook.site for testing.
See API Reference — Webhooks for the complete endpoint list.