Skip to main content

Receiving webhooks

When you ask for an address with POST /v1/requests, the response does not contain the address. The recipient has to confirm it first. VayaPin calls your webhook once the address is known.

One webhook event exists today: pin.address_updated.

Using the WooCommerce plugin?

The plugin receives and verifies these webhooks for you — configure it in Notifications and inspect deliveries in Webhook Logs. This page is for integrating the API directly.

The plugin also surfaces pin.created_at_checkout, pin.autofill_used and pin.optin_declined. Those are emitted by the plugin's own checkout flow — the API sends only pin.address_updated.

How delivery works​

  1. You register an endpoint URL at Dashboard → VayaCore → Webhooks, and receive a signing secret. It is shown once, so copy it then.
  2. You call POST /v1/requests for a recipient.
  3. The recipient confirms their address.
  4. VayaPin sends pin.address_updated to your endpoint.

You only receive events for pins you requested. A pin you never asked about never produces a webhook.

You also receive an event when a recipient changes an address you previously received, so an endpoint must handle repeat events for the same request_id.

Request format​

VayaPin sends POST with a JSON body and these headers:

HeaderValue
Content-Typeapplication/json
User-AgentVayaPin-Webhook/1
X-VayaPin-Signaturesha256=<hex> — HMAC-SHA256 of the raw body
X-VayaPin-Event-IdUnique id for this event

Payload​

{
"event_id": "7f3c1a2e-5d64-4b8f-9a01-2c7e5f8d1b44",
"event_type": "pin.address_updated",
"occurred_at": "2026-10-15T09:21:44Z",
"request_id": "c6f8b1d9-7b0e-4d1f-8b2a-7f2b9e88d2b3",
"vayapin_id": "DK:LARSEN12",
"scripts": {
"latin": { "label": "Nørrebrogade 42, 2200 København N, Danmark",
"display": "Norrebrogade 42, 2200 Kobenhavn N, Denmark" },
"native": { "label": "Nørrebrogade 42, 2200 København N, Danmark",
"display": "Nørrebrogade 42, 2200 København N, Danmark" }
},
"gps": { "lat": 55.6951, "lng": 12.5521 },
"qrcode": { "img_url": "https://.../qrcode.png" },
"contact": { "email": "john@example.com", "phone": "+4512345678", "name": "John Example" }
}
FieldNotes
event_idStable across retries of the same event. Use it to deduplicate.
request_idThe id returned by POST /v1/requests, a UUID string. Use it to match the event to your order. Store and compare it as a string.
vayapin_idThe recipient's VayaPin id, e.g. DK:LARSEN12.
scriptsOne-line formatted address. latin is always present. native appears only when the country's own language differs from Latin.
gpsMay be null if no location is stored.
contactEvery field inside it is optional and can be null. Match orders on request_id, not on contact.

Choosing what to print​

Print display. Use scripts.native.display for a shipping label in the destination country, and fall back to scripts.latin.display whenever native is missing.

label exists for a caller that wants the English-formatted string in every entry. If you print one field, print display.

Either display can be null when VayaPin could not geocode the address. scripts.latin is always present, but a null display means the event carries no printable address. Keep the order in its waiting state and do not print the word "null".

Checking the signature​

Compute HMAC-SHA256 over the raw request body using your signing secret, and compare it to X-VayaPin-Signature.

Two rules matter:

  • Sign the raw bytes you received. Parsing the JSON and re-serializing it changes key order and produces a different signature.
  • Compare in constant time.

The header is sent as sha256=<hex>. Strip the prefix before comparing, or compare against the full prefixed string.

import hmac, hashlib

def verify(raw_body: bytes, secret: str, header: str) -> bool:
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
received = header.removeprefix("sha256=")
return hmac.compare_digest(expected, received)
const crypto = require("crypto");

function verify(rawBody, secret, header) {
const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const received = header.replace(/^sha256=/, "");
const a = Buffer.from(expected), b = Buffer.from(received);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Read the raw body

Most frameworks parse JSON before your handler runs. Express needs express.raw({ type: "application/json" }). FastAPI needs await request.body(). Using the parsed-and-re-serialized body will fail verification.

Reject any request whose signature does not match, and return 401.

Responding​

Return 2xx as soon as you store the event. Do the slow work afterwards.

Return 2xx as well for a request_id you do not recognise, and log it. A 4xx tells VayaPin your endpoint is misconfigured and stops every retry.

You can receive an event for a request you have not finished storing. On the express path the webhook is sent as soon as POST /v1/requests resolves, which can reach your endpoint before that call returns to you. Store the request_id before you do anything slow.

Your responseWhat VayaPin does
2xxMarks the delivery successful.
5xx, timeout, connection failureRetries, up to 5 attempts with increasing delay.
4xxStops. No retry.

A 4xx is treated as permanent: a wrong URL, a wrong secret, or a handler that is not installed. None of those improve by retrying, so fix the cause and the next event will be delivered.

Requests time out after 10 seconds.

Handling retries​

A retry resends the same event_id and the same occurred_at. Store event_id, and ignore an event you already processed.

The rule is the event_id, not the request_id:

What arrivesWhat it isWhat to do
Same event_id as one you processedA retryIgnore it. Return 2xx.
New event_id, request_id you knowThe recipient changed the addressApply it. It replaces the address you hold.

Deliveries for one event never overlap, so you will not receive two copies at the same moment. VayaPin does not promise an order between two different events, so treat the newest one you receive as current.

An account has one enabled webhook endpoint, and every event goes to it.

Local development​

Your endpoint must be reachable over the public internet. For local work, use a tunnel:

ngrok http 3000

Register the tunnel URL, then replay a stored payload against localhost to test your handler without waiting for a real event.

Testing without calling the API​

Build the signature yourself and POST it to your own handler. This keeps your tests offline.

import hmac, hashlib, json

body = json.dumps(payload, separators=(",", ":")).encode()
sig = "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
# POST body with headers {"X-VayaPin-Signature": sig, "X-VayaPin-Event-Id": "evt_test_1"}

Sign exactly the bytes you send. Build the body once, then reuse it.

When a delivery fails​

SymptomLikely cause
Signature mismatchThe secret on your endpoint does not match the one in your VayaPin dashboard. Re-copy both sides.
Connection refused or timeoutWrong URL, server down, or a firewall blocking VayaPin. VayaPin publishes no source IP ranges, so allow the request by its signature, not by its origin.
Repeated 4xxYour handler is rejecting the body. Check that you verify against the raw bytes.

Changing the signing secret takes effect on the next delivery, so update your endpoint first.

If delivery fails for good​

VayaPin stops after 5 failed attempts. There is no endpoint that returns the address afterwards: the webhook is the only way the API delivers it.

GET /v1/pins/{id} is not a substitute. It returns the address as separate components, each as {"encrypted": false, "value": "..."}, and it returns no formatted one-line address. Building a postal address from those components is different for every country.

A component with "encrypted": true always has "value": null. The pin owner protected that field with a password, and the API withholds it. There is nothing to decrypt and no key to ask for. Treat the field as absent.

So keep your endpoint reachable, and alert yourself when a pin.address_updated event does not arrive for an order you expected one for.

Checklist​

  • Check X-VayaPin-Signature against the raw body, in constant time.
  • Return 2xx quickly, then process.
  • Deduplicate on event_id.
  • Match events to orders with request_id.
  • Keep the signing secret in an environment variable.
  • Handle a repeat event for an address that changed.
  • Return 2xx for an unknown request_id, and alert yourself when an expected event never arrives.