Webhooks
Receive events reliably and verify every payload's signature.
Webhooks let Wire notify your server when something happens — most importantly,
when a payment succeeds. Never rely on the checkout redirect alone; treat the
verified payment_intent.succeeded webhook as the source of truth and confirm
server-side.
Webhooks are optional — a payment settles correctly without them. Wire polls the operator itself and updates the PaymentIntent either way. A webhook is only the "tell me the moment it happens" channel; without one you pull the status from the API yourself.
1. Register an endpoint
Create a webhook endpoint pointing at a public HTTPS URL. The signing secret
(prefixed whsec_…) is returned once at creation — store it securely.
ep, err := client.WebhookEndpoints.Create(ctx, &wire.WebhookEndpointCreateParams{
URL: "https://yourshop.mn/webhooks/wire",
EnabledEvents: []string{"payment_intent.succeeded"},
IdempotencyKey: "wh-ep-1",
})
fmt.Println(ep.Secret) // whsec_... — shown only nowep = client.webhook_endpoints.create(
url="https://yourshop.mn/webhooks/wire",
enabled_events=["payment_intent.succeeded"],
idempotency_key="wh-ep-1",
)
print(ep.secret) # whsec_... — shown only nowconst ep = await wire.webhookEndpoints.create({
url: "https://yourshop.mn/webhooks/wire",
enabled_events: ["payment_intent.succeeded"],
idempotencyKey: "wh-ep-1",
});
console.log(ep.secret); // whsec_... — shown only nowcurl https://api.wire.mn/v1/webhook_endpoints \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: wh-ep-1" \
-d '{
"url": "https://yourshop.mn/webhooks/wire",
"enabled_events": ["payment_intent.succeeded"]
}'Events are only dispatched to endpoints that are both verified and enabled. A newly created endpoint must be verified before it receives live events.
An event reaches the endpoints of the project that produced it. If you run several projects — one per site, say — they never receive each other's events.
Each project has exactly one active webhook endpoint. To move to a new address, update the existing endpoint's URL (or delete it first) — the database enforces this, so one event can never fan out to two addresses. If two separate systems need the events, give each its own project.
2. Verify the endpoint
A freshly created endpoint is pending. When you verify it, Wire sends a
signed ping of type endpoint.verification to your URL: answer 2xx and
the endpoint becomes verified and starts receiving live events; otherwise it
stays pending. The ping is signed exactly like a real delivery, so the
verification code below can handle it as-is.
Changing the URL of an already-verified endpoint requires the new address to pass its own ping first — until it does, deliveries keep flowing to the old one without interruption.
Firewall allowlist
Every Wire delivery originates from one fixed IP:
65.109.117.186Allow that address on your endpoint's firewall or WAF and block the rest. The IP check does not replace signature verification — do both.
3. The signature header
Each delivery carries a WirePayment-Signature header:
WirePayment-Signature: t=1717000000,v1=5257a869e7ec...t— the Unix timestamp (seconds) when the signature was generated.v1— a hex-encoded HMAC-SHA256 over the string"<t>.<rawbody>", keyed by your endpoint secret (whsec_…).
To verify manually: compute HMAC_SHA256(secret, t + "." + rawBody), hex-encode
it, and compare to v1 in constant time. Reject deliveries whose timestamp is
outside your tolerance (the SDKs default to 300 seconds).
Event types and payload
Every delivery is a JSON POST with this envelope:
{
"id": "evt_b7nx3k2qw5mv4tzc6jry2pha4g",
"object": "event",
"type": "payment_intent.succeeded",
"created": 1791774042,
"data": {
"id": "pi_k3vq7m2xw5nbt4rj6yhc2dpa7e",
"object": "payment_intent",
"status": "succeeded",
"amount": 15000,
"currency": "MNT",
"charge": "ch_m4tz2q7wn5kc3vbx6ryj2hfa5d"
}
}type | When | data fields |
|---|---|---|
payment_intent.succeeded | The buyer paid. Fulfil the order. | id (pi_…), object, status, amount, currency, charge |
payment_intent.payment_failed | The operator reported the attempt as failed. | same as above |
payment_intent.canceled | The intent was canceled, or expired unpaid. | same, without charge |
charge.refunded | A refund completed through the operator. | id (re_…), object: "refund", charge, amount, status, refunded_total |
enabled_events accepts these four names or "*" (all). Amounts are whole
tögrög. Every event is also stored — fetch it with GET /v1/events/{id} to
double-check a delivery, or list missed ones with GET /v1/events.
Delivery retries: a 5xx, 408, 429 or network error is retried; any other
4xx drops the delivery, so answer 2xx once you've accepted the event.
4. Verify in your handler
Always verify against the raw, unparsed request body — parsing first will change the bytes and break the signature.
func handler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
event, err := client.Webhooks.Verify(
body,
r.Header.Get(wire.SignatureHeader),
endpointSecret, // whsec_...
)
if err != nil {
http.Error(w, "bad signature", http.StatusBadRequest)
return
}
if event.Type == "payment_intent.succeeded" {
// fulfill the order
}
w.WriteHeader(http.StatusOK)
}import wire
event = client.webhooks.verify(
request.body, # raw bytes
request.headers[wire.SIGNATURE_HEADER],
endpoint_secret, # whsec_...
)
if event.type == "payment_intent.succeeded":
... # fulfill the orderimport { SIGNATURE_HEADER } from "@buildry-wire/wire";
const event = wire.webhooks.verify(
rawBody, // unparsed request body (string or Buffer)
req.headers[SIGNATURE_HEADER.toLowerCase()],
endpointSecret, // whsec_...
);
if (event.type === "payment_intent.succeeded") {
// fulfill the order
}Best practices
- Respond fast with
2xx, then do heavy work asynchronously. - Be idempotent — the same event may be delivered more than once. Dedupe on
the event
id. - Double-check server-side: confirm the PaymentIntent status via the API before fulfilling high-value orders.