Batch geocoding with webhooks and idempotency keys

A batch upload is the call you least want to run twice: a network timeout that you retry blindly can submit, and bill, the file again. Two features make it safe. An Idempotency-Key makes the upload repeatable, and a webhook tells you when the job ends so you do not have to poll.

The base URL https://api.addrova.eu is not live yet. Everything below follows the API specification. [À VÉRIFIER : service ouvert, URL de base définitive.]

1. Make the upload repeatable

Send an Idempotency-Key header with the upload. For 24 hours, per tenant, the same key with the same request returns the original result (Idempotent-Replayed: true) and nothing is billed twice. The same key with a different request is refused with 422 idempotency_key_reused; a request still being handled answers 409 idempotency_in_progress.

printf 'adresse\n4 place d'\''Armes Belfort\n' > adresses.csv
KEY=$(date +%s)-upload-1
curl -s https://api.addrova.eu/v1/batch \
  -H "X-API-Key: $ADDROVA_API_KEY" \
  -H "Idempotency-Key: $KEY" \
  -F file=@adresses.csv -F address_column=adresse

Run the same command again with the same KEY and you get the same job back. Generate the key once per logical upload and keep it with your retry state.

The service stores only a SHA-256 fingerprint of the request and, for a batch, the job identifier, never the address or the response (ZL-28).

2. Ask for a webhook

Add webhook_url to the upload: an HTTPS endpoint that is called once, when the job ends, whether it succeeded or failed. The 202 response returns a signing secret once, in webhook_secret (whsec_…); store it, it is not shown again.

curl https://api.addrova.eu/v1/batch \
  -H "X-API-Key: $ADDROVA_API_KEY" \
  -F file=@adresses.csv -F address_column=adresse \
  -F webhook_url=https://example.com/hooks/addrova

The payload carries the job identifier, status, counters and links, never a row of the file nor an address, and the webhook URL and secret are never logged (ZL-27). Failed deliveries are retried up to 5 times (immediately, then after 10 s, 1 min, 5 min and 30 min) until your endpoint answers 2xx.

3. Verify the signature

The webhook follows the Standard Webhooks format. Three headers matter: webhook-id, webhook-timestamp and webhook-signature. The signature is v1, followed by the base64 HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{body}, keyed with the decoded secret. Reject timestamps older than 5 minutes and de-duplicate on webhook-id. This check runs on its own, with a made-up secret:

import base64
import hashlib
import hmac
import time

def sign(secret: str, msg_id: str, timestamp: int, body: bytes) -> str:
    key = base64.b64decode(secret.removeprefix("whsec_"))
    mac = hmac.new(key, f"{msg_id}.{timestamp}.".encode() + body, hashlib.sha256).digest()
    return "v1," + base64.b64encode(mac).decode()

def verify(secret: str, headers: dict, body: bytes, tolerance: int = 300) -> bool:
    if abs(time.time() - int(headers["webhook-timestamp"])) > tolerance:
        return False
    expected = sign(secret, headers["webhook-id"], int(headers["webhook-timestamp"]), body)
    return any(hmac.compare_digest(expected, part) for part in headers["webhook-signature"].split(" "))

secret = "whsec_" + base64.b64encode(b"a-made-up-signing-key-32-bytes!!").decode()
body = b'{"type":"batch.finished","id":"job_1"}'
now = int(time.time())
headers = {"webhook-id": "msg_1", "webhook-timestamp": str(now), "webhook-signature": sign(secret, "msg_1", now, body)}

assert verify(secret, headers, body)
assert not verify(secret, headers, body + b" ")  # a changed body fails
assert not verify(secret, {**headers, "webhook-timestamp": str(now - 3600)}, body)  # too old
print("signature checks OK")

The official SDKs include a verify_webhook helper, so you rarely write this yourself: see the SDK page.

Checklist

  • Generate one Idempotency-Key per logical upload, reuse it on every retry.
  • Store the webhook_secret when you receive it.
  • Verify the signature before you trust the payload; answer 2xx fast and do the work afterwards.
  • De-duplicate on webhook-id: a retry can deliver the same event twice.
  • Poll the job only as a fallback: see the batch reference.