Guides

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:

ResourceEvents
Invoicesinvoice.created, invoice.updated, invoice.deleted, invoice.approved, invoice.sent, invoice.paid, invoice.cancelled, invoice.overdue
Customerscustomer.created, customer.updated, customer.deleted
Productsproduct.created, product.updated, product.deleted
Expensesexpense.created, expense.updated, expense.deleted
Supplierssupplier.created, supplier.updated, supplier.deleted
Transactionstransaction.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:

HeaderDescription
Talero-Event-IdStable ID of the event. The same event keeps this ID across retries — use it for deduplication.
Talero-Delivery-IdUnique ID of this delivery attempt sequence.
Talero-Signaturet=<unix seconds>,v1=<signature> — see below.
Content-Typeapplication/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:

  1. Parse t and v1 from the header.
  2. Reject deliveries whose timestamp is more than 5 minutes from your clock (replay protection).
  3. Compute HMAC-SHA256 over {t} + "." + the raw request bytes — not a re-serialized JSON object.
  4. Compare against v1 with a constant-time comparison.
Node.js
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:

AttemptWait before next attempt
11 minute
22 minutes
34 minutes
48 minutes
516 minutes
632 minutes
71 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.