Webhooks
Webhooks push signed event notifications to your server as things happen in Talero, so you do not have to poll. Subscriptions are managed entirely through the API and require the webhooks:manage scope.
Event catalog
21 event types are available:
| Resource | Events |
|---|---|
| Invoices | invoice.created, invoice.updated, invoice.deleted, invoice.approved, invoice.sent, invoice.paid, invoice.cancelled, invoice.overdue |
| Customers | customer.created, customer.updated, customer.deleted |
| Products | product.created, product.updated, product.deleted |
| Expenses | expense.created, expense.updated, expense.deleted |
| Suppliers | supplier.created, supplier.updated, supplier.deleted |
| Transactions | transaction.created |
Managing subscriptions
- GET
/api/v1/webhooksList subscriptions - POST
/api/v1/webhooksCreate a subscription - DELETE
/api/v1/webhooks/{id}Delete a subscription - GET
/api/v1/webhooks/{id}/deliveriesDelivery history (latest 100) - POST
/api/v1/webhooks/{id}/deliveries/{delivery_id}/replayReplay a delivery - POST
/api/v1/webhooks/{id}/rotate-secretRotate the signing secret
Create a subscription with the destination URL and the events you want. The URL must be HTTPS, credential-free and publicly resolvable. A company can have at most 25 active subscriptions.
curl -X POST https://app.talero.dk/api/v1/webhooks \
-H "X-API-Key: $TALERO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/talero/webhooks",
"events": ["invoice.approved", "invoice.paid", "invoice.overdue"]
}'
Response — 201 Created
{
"data": {
"id": "b4a2c1d0-9e8f-4a7b-6c5d-3e2f1a0b9c8d",
"url": "https://example.com/talero/webhooks",
"events": ["invoice.approved", "invoice.paid", "invoice.overdue"],
"secret": "whsec_9f8e7d6c5b4a39281706f5e4d3c2b1a0",
"message": "Store this secret securely — it will not be shown again. Use it to verify webhook signatures."
}
}
The whsec_ signing secret is returned only at creation and after rotation. Store it in your secret manager — you need it to verify every delivery.
Delivery format
Events are delivered as POST requests with a JSON body and these headers:
| Header | Description |
|---|---|
Talero-Event-Id | Stable ID of the event. The same event keeps this ID across retries — use it for deduplication. |
Talero-Delivery-Id | Unique ID of this delivery attempt sequence. |
Talero-Signature | t=<unix seconds>,v1=<signature> — see below. |
Content-Type | application/json |
The body is an event envelope; data contains the resource in the same shape the API returns it:
{
"id": "0c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f",
"type": "invoice.paid",
"api_version": "1.0.0",
"created_at": "2026-08-02T09:15:27.318Z",
"data": {
"id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"invoice_number": "1042",
"status": "paid",
"total": 10000.00,
"currency": "DKK"
}
}
Respond with any 2xx status within a few seconds to acknowledge the delivery. Process the payload asynchronously if your handling is slow. Deliveries can arrive out of order and, in rare cases, more than once — make your handler idempotent using Talero-Event-Id.
Verifying signatures
Every delivery is signed with your subscription's whsec_ secret. The Talero-Signature header has the form:
Talero-Signature: t=1754126127,v1=mE9WLJ2rV8kThQnUuY3aPzXo41GdBcf7sD0iKwHqRTA
where v1 is the base64url-encoded (unpadded) HMAC-SHA256 of the string "{t}.{raw request body}", keyed with the secret. To verify:
- Parse
tandv1from the header. - Reject deliveries whose timestamp is more than 5 minutes from your clock (replay protection).
- Compute HMAC-SHA256 over
{t}+"."+ the raw request bytes — not a re-serialized JSON object. - Compare against
v1with a constant-time comparison.
const crypto = require('crypto');
const TOLERANCE_SECONDS = 300; // 5 minutes
function verifyTaleroSignature(secret, signatureHeader, rawBody) {
const match = /^t=(\d+),v1=([A-Za-z0-9_-]+)$/.exec(signatureHeader || '');
if (!match) return false;
const timestamp = Number(match[1]);
const nowSeconds = Math.floor(Date.now() / 1000);
if (Math.abs(nowSeconds - timestamp) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.`)
.update(rawBody) // raw Buffer, not re-serialized JSON
.digest('base64url');
const a = Buffer.from(match[2]);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express: keep the raw body for verification.
// app.post('/talero/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
// if (!verifyTaleroSignature(process.env.TALERO_WEBHOOK_SECRET,
// req.header('Talero-Signature'), req.body)) {
// return res.status(400).send('invalid signature');
// }
// const event = JSON.parse(req.body);
// res.sendStatus(200);
// });
Python
import base64
import hashlib
import hmac
import re
import time
TOLERANCE_SECONDS = 300 # 5 minutes
def verify_talero_signature(secret: str, signature_header: str, raw_body: bytes) -> bool:
match = re.fullmatch(r"t=(\d+),v1=([A-Za-z0-9_-]+)", signature_header or "")
if not match:
return False
timestamp = int(match.group(1))
if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
return False
mac = hmac.new(
secret.encode(),
f"{timestamp}.".encode() + raw_body, # raw bytes, not re-serialized JSON
hashlib.sha256,
)
expected = base64.urlsafe_b64encode(mac.digest()).rstrip(b"=").decode()
return hmac.compare_digest(expected, match.group(2))
Retries and dead-lettering
A delivery is considered failed if your endpoint is unreachable or returns a non-2xx status. Talero makes up to 8 attempts with exponential backoff, starting at 60 seconds and doubling up to a cap of one hour:
| Attempt | Wait before next attempt |
|---|---|
| 1 | 1 minute |
| 2 | 2 minutes |
| 3 | 4 minutes |
| 4 | 8 minutes |
| 5 | 16 minutes |
| 6 | 32 minutes |
| 7 | 1 hour (cap) |
| 8 | — moved to dead letter |
After the final failed attempt the delivery is marked dead_letter and no further automatic attempts are made. Inspect failures via the delivery history endpoint, fix your receiver, then replay:
curl -X POST \
"https://app.talero.dk/api/v1/webhooks/{id}/deliveries/{delivery_id}/replay" \
-H "X-API-Key: $TALERO_API_KEY"
Replay resets the delivery to pending and restarts the retry schedule with the original payload and signature scheme (a fresh timestamp is used at send time).
Rotating the signing secret
Rotate a subscription's secret if it may have been exposed:
curl -X POST "https://app.talero.dk/api/v1/webhooks/{id}/rotate-secret" \
-H "X-API-Key: $TALERO_API_KEY"
Response — 200 OK
{
"data": {
"id": "b4a2c1d0-9e8f-4a7b-6c5d-3e2f1a0b9c8d",
"secret": "whsec_1a2b3c4d5e6f708192a3b4c5d6e7f809",
"message": "Store this secret securely — it will not be shown again."
}
}
Rotation takes effect immediately: deliveries signed with the old secret stop as soon as the new secret is stored. Deploy the new secret to your receiver right after rotating.