Menu

Webhooks for official rates

You can receive a webhook when a new official rate publication is collected or an existing publication is corrected. Add an endpoint in your dashboard, verify each signed request, then fetch the updated rates.

Events you can receive

  • official.published: A new official rate publication was collected, such as a weekly Indonesian tax rate table. Its version is 1.
  • official.corrected: An authority changed a publication already collected. It has the same publication and a higher version. You can still read the earlier version.
  • webhook.test: You send this event from your dashboard to one endpoint. Its data is {"endpoint":"<endpoint id>"}.

Events tell you what changed. They do not include the rates. Fetch the rows with GET /v1/official/{country}/{type}/publications/{publication_id} or the rate endpoints. See the Official rates API reference.

Official events cover the same changes as GET /v1/official/changes. A first version whose valid_from is more than about a month old does not produce an event when historical data is loaded.

Endpoint limits

You can add up to 5 endpoints on Plus or 25 on Pro. Free and Basic do not include webhooks. See pricing.

Add an endpoint

  1. Open Webhooks in your dashboard.
  2. Click Add endpoint.
  3. Enter your endpoint URL.
  4. Under Send events for, choose New rates published, Published rates corrected, or both.
  5. Optionally add a description of up to 200 characters, then add the endpoint.

Your URL must use https://, port 443 and a public hostname. It cannot contain a user name or password and must be no longer than 2048 characters. You cannot use localhost, names ending in .local or .internal, or private, loopback or link-local addresses. The address is checked before every delivery. A hostname that later points to a private address is refused.

After you add the endpoint, you see its signing secret once. It starts with whsec_. Copy it into your server's secret store. You cannot view it again. If you lose it, rotate it.

Deleting an endpoint stops all deliveries to it.

Read the request

You receive a POST request at your endpoint URL. Redirects are not followed. A 3xx response counts as a failure. Only your response status code is read, never the response body.

HeaderValue
Content-Typeapplication/json
User-Agentwebhook-delivery/1
Webhook-Signaturet=<unix seconds>,v1=<hex>

The body is compact JSON with four top-level fields: id, type, created_at and data. For official events, data contains the six fields listed below.

Official event IDs look like evt_idn-kurs-pajak_2026-10-07_v1, identifying the table, publication and version. Test event IDs look like evt_test_<unix milliseconds>.

FieldMeaning
idA unique event ID string. Use it to de-duplicate events.
typeThe event type: official.published, official.corrected or webhook.test.
created_atAn ISO 8601 UTC timestamp ending in Z. It may include fractional seconds. For official events, this is when that version was retrieved.
dataAn object containing the event details. For webhook.test, it contains only endpoint, the endpoint ID.
data.countryAn ISO 3166 alpha-3 country code, such as IDN.
data.typesAn array of the table's type words, such as ["tax","customs"].
data.publicationThe publication ID string, often a date.
data.versionAn integer: 1 for a new publication, higher for a correction.
data.valid_fromThe first validity date in YYYY-MM-DD format.
data.valid_toThe last validity date in YYYY-MM-DD format, or null when valid until the next publication.

The examples below show the bodies for official.published, official.corrected and webhook.test.

official.published body
{  "created_at": "2026-10-07T01:15:04Z",  "data": {    "country": "IDN",    "publication": "2026-10-07",    "types": [      "tax",      "customs"    ],    "valid_from": "2026-10-07",    "valid_to": "2026-10-13",    "version": 1  },  "id": "evt_idn-kurs-pajak_2026-10-07_v1",  "type": "official.published"}
official.corrected body
{  "created_at": "2026-10-08T03:02:41Z",  "data": {    "country": "IDN",    "publication": "2026-10-07",    "types": [      "tax",      "customs"    ],    "valid_from": "2026-10-07",    "valid_to": "2026-10-13",    "version": 2  },  "id": "evt_idn-kurs-pajak_2026-10-07_v2",  "type": "official.corrected"}
webhook.test body
{  "created_at": "2026-10-16T09:30:12.345000Z",  "data": {    "endpoint": "3f0c6a52-6a2e-4b8e-9c1d-2f4b7e9a1c03"  },  "id": "evt_test_1792143012345",  "type": "webhook.test"}

Verify signatures

Verify Webhook-Signature before you process an event. Use the raw body bytes exactly as received. Do not parse and re-serialize the JSON before checking the signature.

  1. Split Webhook-Signature on commas to read t and v1.
  2. Reject the request if t is more than 300 seconds (5 minutes) ahead of or behind your current time. Keep your server clock in sync to apply this replay check.
  3. Build the signed message from the value of t, a full stop character and the raw body bytes: <t>. followed by the body.
  4. Compute HMAC-SHA256 of that message using your endpoint's signing secret as the key. Encode the result as hexadecimal.
  5. Compare the result with v1 using a constant-time comparison. Reject the request if they do not match.
import { createHmac, timingSafeEqual } from 'node:crypto'import { createServer } from 'node:http'const TOLERANCE_SECONDS = 300// rawBody: the request body exactly as received (a Buffer), before any JSON parsing.function verifyWebhook(rawBody, header, secret, now = Math.floor(Date.now() / 1000)) {  const fields = {}  for (const part of String(header ?? '').split(',')) {    const i = part.indexOf('=')    if (i > 0) fields[part.slice(0, i).trim()] = part.slice(i + 1).trim()  }  const t = Number(fields.t)  if (!Number.isInteger(t) || Math.abs(now - t) > TOLERANCE_SECONDS) return false  const expected = createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest()  const given = Buffer.from(fields.v1 ?? '', 'hex')  return given.length === expected.length && timingSafeEqual(given, expected)}const seen = new Set() // use your database in productioncreateServer((req, res) => {  const chunks = []  req.on('data', (chunk) => chunks.push(chunk))  req.on('end', () => {    const rawBody = Buffer.concat(chunks)    const header = req.headers['webhook-signature']    if (!verifyWebhook(rawBody, header, process.env.WEBHOOK_SECRET)) {      res.writeHead(400).end()      return    }    const event = JSON.parse(rawBody)    if (!seen.has(event.id)) {      seen.add(event.id)      // Hand the event to a queue or background job here, then answer quickly.      console.log(event.type, event.data)    }    res.writeHead(204).end()  })}).listen(Number(process.env.PORT ?? 3000))

Respond and handle retries

Return any 2xx status within 10 seconds to acknowledge delivery. A 3xx, 4xx, 5xx, timeout or connection error counts as a failed attempt.

Verify the signature, store the event and return 2xx quickly. Do slow work in the background.

After a failure, retries follow the schedule below. There are 6 attempts in total over about 8.5 hours. After the last failed attempt, the event is not sent again. There is no email fallback.

  • First retry: 1 minute after the initial failure.
  • Second retry: 5 minutes after the previous failure.
  • Third retry: 30 minutes after the previous failure.
  • Fourth retry: 2 hours after the previous failure.
  • Fifth and final retry: 6 hours after the previous failure.

Each attempt is signed again with a fresh timestamp, so retries can pass your signature time check.

The same event ID is never queued twice for one endpoint, but you can still receive it more than once. For example, your 2xx response may arrive after the 10 second limit. Store the IDs you have handled and skip repeats.

Events can arrive in any order. Use version to identify the newest version of a publication. If you miss events, catch up with GET /v1/official/changes?since=....

Rotate your signing secret

In your dashboard, choose Rotate secret on the endpoint. Copy the new secret when it appears. You see it only once. Rotate your secret if it may have leaked or you have lost it.

The old secret stops being used immediately. Deliveries from then on are signed with the new secret.

To avoid failed deliveries during the switch, let your server accept either secret for a few minutes while you update it. Then remove the old secret. Any delivery that fails during the switch is retried on the schedule above.

Send a test event

In your dashboard, choose Send test on an endpoint. This queues a webhook.test event for that endpoint only. It usually arrives within a minute.

The test event is signed like any other event and follows the same retry rules. Use it to check your URL, signature verification and 2xx response.

Questions

Can you receive webhooks on localhost while developing?

You need a public hostname using `https://` on port `443`. You cannot use `localhost`, `.local` or `.internal` names, or private, loopback or link-local addresses.

Do events include the rates?

No. Events identify what changed. Fetch the rows with `GET /v1/official/{country}/{type}/publications/{publication_id}` or the rate endpoints. See the [Official rates API reference](/docs/official).

What happens if your server is down for a day?

Delivery stops after 6 failed attempts over about 8.5 hours. When your server is available again, use `GET /v1/official/changes?since=...` to catch up on missed changes.

Do you get an email when deliveries fail?

No. Failed deliveries follow the retry schedule. There is no email fallback.

Can you receive the same event more than once?

Yes. A delivery can be retried even if you processed it, such as when your `2xx` response arrives after 10 seconds. Store handled event IDs and skip repeats.