# Webhooks

> Receive signed payment events on your server — set up, verify, deduplicate, and handle retries and ordering.

A webhook is an HTTP `POST` Devzity sends to your server when a payment changes. It is the reliable way to learn that a payment was paid — even if the customer closes their browser.

> **Warning:** Always verify the signature before trusting a webhook. Anyone can send a request to your URL.

## Set up an endpoint

1. **Developers → Webhooks → Add endpoint.** Choose the environment (**Test** or **Live**), enter your HTTPS URL, and pick the events you want (or all of them).
2. Copy the **signing secret** (`whsec_…`). It is shown once; rotate it any time from the endpoint page.
3. Store it as `DEVZITY_WEBHOOK_SECRET` on your server.

Production endpoints must be public `https` URLs. Use **Send test event** on a Test endpoint to check your receiver.

### Test payments go to Test endpoints; live payments to Live endpoints

| Payment | Delivered to |
| --- | --- |
| **Test** payment (`livemode: false`) | your **Test** webhook endpoint(s) only |
| **Live** payment (`livemode: true`) | your **Live** webhook endpoint(s) only |

Each endpoint has its own signing secret. A very common mistake is registering only a Live endpoint and wondering why test payments send nothing — register one for each environment you use, and use the secret of the endpoint that received the request.

## Events

| Type | Sent when |
| --- | --- |
| `payment.created` | A payment is created. |
| `payment.paid` | A payment becomes `PAID`. |
| `payment.cancelled` | A payment is cancelled. |
| `payment.reversed` | A paid payment is reversed in Devzity's records (not an automatic provider refund). |
| `endpoint.test` | You pressed **Send test event** (test endpoints only). Contains no Payment. |

Webhooks describe hosted payments, the same objects `/v1/payments` returns. New event types may be added later: ignore types you don't recognise (the SDK gives you an `unknown` event you can log and skip).

```json
{
  "id": "evt_2q8Zc0mRk1nT",
  "object": "event",
  "api_version": "v1",
  "type": "payment.paid",
  "livemode": false,
  "created_at": "2026-09-20T12:00:00Z",
  "data": { "object": { "id": "…", "status": "PAID", "amount": "500.00" } }
}
```

`data.object` is exactly what `GET /v1/payments/{id}` returns — including your `external_id`, `merchant_reference` and `metadata`, so you can match it to your order.

## Verify the signature

Every request carries `Devzity-Signature: t=<unix time>,v1=<hex>`. The signature is `HMAC-SHA256(secret, "<t>." + rawBody)` over the **exact bytes received** — never a re-serialized JSON. Reject timestamps older than **5 minutes**, and compare in constant time.

**Node.js SDK**

```js
// Receive Devzity webhooks with @devzity/sdk (Node.js 18+, no framework).
import { createServer } from "node:http";
import { DevzitySignatureVerificationError, verifyWebhook } from "@devzity/sdk";

// Demo only: Devzity delivers each event AT LEAST once, so remember the ids you have handled.
// In production use your database (a table with a unique event_id) instead of a Set.
const processed = new Set();

createServer(async (req, res) => {
  if (req.method !== "POST" || req.url !== "/devzity/webhook") {
    res.writeHead(404).end();
    return;
  }

  // 1. Read the RAW body — do not JSON.parse before verifying.
  const chunks = [];
  for await (const chunk of req) chunks.push(chunk);
  const rawBody = Buffer.concat(chunks);

  // 2. Verify the signature. Never trust an unverified webhook.
  let event;
  try {
    event = verifyWebhook(rawBody, req.headers["devzity-signature"], process.env.DEVZITY_WEBHOOK_SECRET);
  } catch (error) {
    if (error instanceof DevzitySignatureVerificationError) {
      res.writeHead(400).end();
      return;
    }
    throw error;
  }

  // 3. Already handled this event? Acknowledge and stop — never fulfil twice.
  if (processed.has(event.id)) {
    res.writeHead(200).end();
    return;
  }

  // 4. Fulfil ONLY from a verified payment.paid event. Ignore anything you don't recognise.
  if (event.type === "payment.paid") {
    console.log("Fulfil the order for payment", event.data.object.id);
  }
  processed.add(event.id);

  // 5. Any 2xx acknowledges the event. Don't rely on delivery order.
  res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify({ received: true }));
}).listen(Number(process.env.PORT ?? 3000));
```

**Next.js**

```ts
import { verifyDevzityWebhook } from "@devzity/next";

// Demo only: Devzity delivers each event AT LEAST once. In production keep processed event ids in
// your database (a unique constraint on event.id) instead of in memory.
const processed = new Set<string>();

export async function POST(request: Request) {
  // Reads the RAW body and DEVZITY_WEBHOOK_SECRET; throws if the signature is not valid.
  const event = await verifyDevzityWebhook(request);

  if (processed.has(event.id)) return Response.json({ received: true }); // already handled

  if (event.type === "payment.paid") {
    // Fulfil the order for event.data.object.id — only from a verified payment.paid event.
  }
  processed.add(event.id);

  return Response.json({ received: true });
}
```

**Node.js (no SDK)**

```js
// Verify a Devzity webhook with only node:crypto (no SDK, no dependencies).
import { createHmac, timingSafeEqual } from "node:crypto";

/** `header` is "t=<unix>,v1=<hex>"; the signed text is "<t>." + the RAW body bytes. */
export function verifyDevzitySignature(secret, header, rawBody, now = Date.now() / 1000) {
  const parts = Object.fromEntries(header.split(",").map((part) => part.trim().split("=", 2)));
  const timestamp = parts.t ?? "";
  const signature = parts.v1 ?? "";
  if (!/^\d+$/.test(timestamp) || !/^[0-9a-f]{64}$/i.test(signature)) return false;
  if (Math.abs(now - Number(timestamp)) > 300) return false; // outside the 5 minute tolerance

  const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest();
  return timingSafeEqual(expected, Buffer.from(signature, "hex"));
}
```

**PHP**

```php
<?php
// Receive a Devzity webhook in plain PHP.
$secret = getenv('DEVZITY_WEBHOOK_SECRET');

$rawBody = file_get_contents('php://input');           // 1. the RAW body, before json_decode
$header = $_SERVER['HTTP_DEVZITY_SIGNATURE'] ?? '';

// 2. Verify: header is "t=<unix>,v1=<hex>"; the signed text is "<t>.<rawBody>".
parse_str(str_replace(',', '&', $header), $parts);
$timestamp = $parts['t'] ?? '';
$signature = $parts['v1'] ?? '';
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);

if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300 || !hash_equals($expected, $signature)) {
    http_response_code(400);   // never trust an unverified webhook
    exit;
}

$event = json_decode($rawBody, true);

// 3. Deduplicate on $event['id'] (delivery is at-least-once) and do not assume order.
if ($event['type'] === 'payment.paid') {
    $paymentId = $event['data']['object']['id'];
    // fulfil the order for $paymentId
}

http_response_code(200);        // 4. any 2xx acknowledges the event
echo json_encode(['received' => true]);
```

**Java**

```java
package com.example.devzity;

import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Map;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class DevzityController {

    private final DevzityService devzity;
    private final String webhookSecret;

    public DevzityController(DevzityService devzity, @Value("${devzity.webhook-secret:}") String webhookSecret) {
        this.devzity = devzity;
        this.webhookSecret = webhookSecret;
    }

    @PostMapping("/pay")
    public Map<String, String> pay() {
        return Map.of("checkoutUrl", devzity.createPayment("500.00"));
    }

    /** Take the body as a raw String so the exact bytes Devzity signed are verified. */
    @PostMapping("/devzity/webhook")
    public ResponseEntity<String> webhook(
            @RequestBody String rawBody,
            @RequestHeader(value = "Devzity-Signature", defaultValue = "") String header)
            throws NoSuchAlgorithmException, InvalidKeyException {
        if (!isValid(rawBody, header)) {
            return ResponseEntity.badRequest().build(); // never trust an unverified webhook
        }
        // Parse rawBody with Jackson now, deduplicate on event.id, handle payment.paid.
        return ResponseEntity.ok("{\"received\":true}");
    }

    static boolean isValid(String rawBody, String header) throws NoSuchAlgorithmException, InvalidKeyException {
        return isValid(rawBody, header, System.getenv().getOrDefault("DEVZITY_WEBHOOK_SECRET", ""), System.currentTimeMillis() / 1000);
    }

    static boolean isValid(String rawBody, String header, String secret, long nowSeconds)
            throws NoSuchAlgorithmException, InvalidKeyException {
        String timestamp = null;
        String signature = null;
        for (String part : header.split(",")) {
            String[] kv = part.trim().split("=", 2);
            if (kv.length == 2 && kv[0].equals("t")) timestamp = kv[1];
            if (kv.length == 2 && kv[0].equals("v1")) signature = kv[1];
        }
        if (timestamp == null || signature == null || !timestamp.matches("\\d+")) return false;
        if (Math.abs(nowSeconds - Long.parseLong(timestamp)) > 300) return false;

        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        byte[] expected = mac.doFinal((timestamp + "." + rawBody).getBytes(StandardCharsets.UTF_8));
        StringBuilder hex = new StringBuilder();
        for (byte b : expected) hex.append(String.format("%02x", b));
        return MessageDigest.isEqual(hex.toString().getBytes(StandardCharsets.UTF_8), signature.getBytes(StandardCharsets.UTF_8));
    }
}
```

**Go**

```go
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"io"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"
)

// verifyDevzitySignature checks "t=<unix>,v1=<hex>" over "<t>." + the RAW body.
func verifyDevzitySignature(secret, header string, rawBody []byte, now time.Time) bool {
	var timestamp, signature string
	for _, part := range strings.Split(header, ",") {
		key, value, _ := strings.Cut(strings.TrimSpace(part), "=")
		switch key {
		case "t":
			timestamp = value
		case "v1":
			signature = value
		}
	}
	sentAt, err := strconv.ParseInt(timestamp, 10, 64)
	if err != nil || signature == "" {
		return false
	}
	if diff := now.Unix() - sentAt; diff > 300 || diff < -300 {
		return false // outside the 5 minute tolerance
	}

	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(timestamp + "."))
	mac.Write(rawBody)
	expected := mac.Sum(nil)

	given, err := hex.DecodeString(signature)
	return err == nil && hmac.Equal(expected, given)
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
	rawBody, err := io.ReadAll(r.Body) // 1. the RAW body, before decoding
	if err != nil {
		http.Error(w, "bad request", http.StatusBadRequest)
		return
	}
	// 2. Verify. Never trust an unverified webhook.
	if !verifyDevzitySignature(os.Getenv("DEVZITY_WEBHOOK_SECRET"), r.Header.Get("Devzity-Signature"), rawBody, time.Now()) {
		http.Error(w, "invalid signature", http.StatusBadRequest)
		return
	}

	var event struct {
		ID   string `json:"id"`
		Type string `json:"type"`
		Data struct {
			Object struct {
				ID string `json:"id"`
			} `json:"object"`
		} `json:"data"`
	}
	_ = json.Unmarshal(rawBody, &event)

	// 3. Deduplicate on event.ID (delivery is at-least-once) and do not assume order.
	if event.Type == "payment.paid" {
		_ = event.Data.Object.ID // fulfil the order for this payment
	}

	w.WriteHeader(http.StatusOK) // 4. any 2xx acknowledges the event
}
```

**Python**

```python
"""Verify a Devzity webhook (standard library only)."""
import hashlib
import hmac
import time
from typing import Optional


def verify_devzity_signature(secret: str, header: str, raw_body: bytes, now: Optional[float] = None) -> bool:
    """`header` is "t=<unix>,v1=<hex>"; the signed text is "<t>." + the RAW body bytes."""
    parts = dict(item.strip().split("=", 1) for item in header.split(",") if "=" in item)
    timestamp, signature = parts.get("t", ""), parts.get("v1", "")
    if not timestamp.isdigit() or not signature:
        return False
    if abs((now if now is not None else time.time()) - int(timestamp)) > 300:
        return False  # outside the 5 minute tolerance
    expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)


# Flask example:
#   @app.post("/devzity/webhook")
#   def webhook():
#       raw = request.get_data()  # the RAW body, before request.json
#       if not verify_devzity_signature(os.environ["DEVZITY_WEBHOOK_SECRET"], request.headers.get("Devzity-Signature", ""), raw):
#           return "", 400        # never trust an unverified webhook
#       event = json.loads(raw)   # deduplicate on event["id"]; delivery is at-least-once
#       return {"received": True}
```

The Node.js, Java, Go and Python verifiers are automatically tested against the shared [signature vectors](https://docs.devzity.com/v1/webhooks.md#test-vectors). The PHP verifier implements the same algorithm and has not been run in automated tests yet.

### Read the raw body

Read the body **before** any JSON parsing, or the signature can't match. Framework notes:

- **Next.js** — use `verifyDevzityWebhook(request)` (it calls `request.text()`), or call `request.text()` yourself. Never call `request.json()` first: the body can only be read once.
- **Express** — mount `express.raw({ type: "application/json" })` for the webhook route, before any `express.json()`.
- **Laravel** — use `$request->getContent()`, not `$request->all()` or `->json()`, and exclude the route from CSRF verification.
- **Spring Boot** — take the body as `@RequestBody String rawBody`; parse it yourself after verifying, rather than binding to a DTO.

## Handle events safely

1. **Verify** the signature (above).
2. **Deduplicate on `event.id`.** If you have already processed that id, return `200` and do nothing.
3. **Fulfil only from a verified `payment.paid`** — then mark the id processed.

```text
if event already processed → return 200
if event.type == "payment.paid" → fulfil the order for event.data.object
record event.id as processed → return 200
```

Keep processed ids in your database (a unique constraint on `event.id` makes this race-safe). Delivery is **at least once**, never exactly once.

## Delivery and reliability

- **Respond `2xx` quickly** to acknowledge. Redirects are not followed; `3xx`, `4xx`, `5xx` and timeouts (10 s) count as failures.
- **Retries:** failed deliveries are retried immediately, then after 30 s, 2 min, 10 min, 30 min, 2 h and 6 h. After that you can resend from the dashboard.
- **Duplicates happen.** A retry sends the **same** `id` and the same body.
- **Do not rely on arrival order.** `payment.created` may arrive after `payment.paid`. When your logic is sensitive to order, retrieve the payment (`GET /v1/payments/{id}`) and act on its current status rather than on the event alone.
- An endpoint can be **disabled** at any time; it then receives nothing new. One that keeps failing is flagged **Failing** in the dashboard.

## Test vectors

To check your own verification code, `webhook-signature-vectors.json` in the repository lists deterministic `(secret, timestamp, body) → signature` cases. All Devzity SDKs and examples must reproduce them byte for byte.
