Standard Webhooks libraries
Our webhooks follow Standard Webhooks, an open specification with official libraries. Verify with them; do not write your own signature check.
| Language | Package | Install |
|---|---|---|
| Node | standardwebhooks |
npm install standardwebhooks |
| PHP | standard-webhooks/standard-webhooks |
composer require standard-webhooks/standard-webhooks |
| Python | standardwebhooks |
pip install standardwebhooks |
| Go | libraries/go |
go get github.com/standard-webhooks/standard-webhooks/libraries |
The repository also has libraries for Java, C#, Ruby, Rust and Elixir.
Pass the library your endpoint’s secret as it is, whsec_…: the libraries read the prefix. Give it the raw
body and the request’s headers; it checks the signature and refuses a timestamp more than 5 minutes off.
// Receive webhooks: verify each one with the Standard Webhooks library, act on it once.import { createServer } from "node:http";import { Webhook } from "standardwebhooks";
const webhook = new Webhook(process.env.WEBHOOK_SECRET); // whsec_…, answered once by PUT /v1/webhook-endpointconst handled = new Set(); // webhook-id of the events acted on; keep this in your databaseconst versions = new Map(); // the highest version applied, per payment, refund or payout
createServer((req, res) => { const chunks = []; req.on("data", (chunk) => chunks.push(chunk)); req.on("end", () => { let event; try { // The exact bytes received, before any JSON parsing; the timestamp must be within 5 minutes. event = webhook.verify(Buffer.concat(chunks).toString("utf8"), req.headers); } catch { res.writeHead(400).end(); return; } if (!handled.has(req.headers["webhook-id"])) { handled.add(req.headers["webhook-id"]); apply(event); } res.writeHead(204).end(); // answer 2xx within 15 seconds; do slow work after answering });}).listen(Number(process.env.PORT ?? 8080));
function apply(event) { const [subject, version] = { "payment.status_changed": [event.payment_id, event.payment_version], "refund.status_changed": [event.refund_id, event.refund_version], "payout.status_changed": [event.payout_id, event.payout_version], }[event.type] ?? [event.event_id, 0]; // Events may come late or twice: an older version than the one applied changes nothing. if ((versions.get(subject) ?? 0) > version) return; versions.set(subject, version); console.log(`verified ${event.type} ${subject} v${version} ${event.status ?? ""}`);}<?php// Receive one webhook: verify it with the Standard Webhooks library, act on it once.// Serve it with your web server, or try it with: php -S 0.0.0.0:8080 webhooks/receive.php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use StandardWebhooks\Webhook;use StandardWebhooks\Exception\WebhookVerificationException;
$webhook = new Webhook(getenv('WEBHOOK_SECRET')); // whsec_…, answered once by PUT /v1/webhook-endpoint$raw = file_get_contents('php://input'); // the exact bytes received, before any JSON parsing$headers = array_change_key_case(getallheaders(), CASE_LOWER);try { $event = $webhook->verify($raw, $headers); // the timestamp must be within 5 minutes} catch (WebhookVerificationException) { http_response_code(400); exit;}
// Act once per webhook-id, and never apply an older version over a newer one: keep both in// your database. Events may come late or twice.[$subjectField, $versionField] = match ($event['type']) { 'payment.status_changed' => ['payment_id', 'payment_version'], 'refund.status_changed' => ['refund_id', 'refund_version'], 'payout.status_changed' => ['payout_id', 'payout_version'], default => ['event_id', null],};$version = $versionField === null ? 0 : $event[$versionField];error_log(sprintf('verified %s %s v%d %s', $event['type'], $event[$subjectField], $version, $event['status'] ?? ''));
http_response_code(204); // answer 2xx within 15 seconds; do slow work after answering"""Receive webhooks: verify each one with the Standard Webhooks library, act on it once."""import osfrom http.server import BaseHTTPRequestHandler, HTTPServer
from standardwebhooks.webhooks import Webhook, WebhookVerificationError
webhook = Webhook(os.environ["WEBHOOK_SECRET"]) # whsec_…, answered once by PUT /v1/webhook-endpointhandled = set() # webhook-id of the events acted on; keep this in your databaseversions = {} # the highest version applied, per payment, refund or payout
def apply(event): subject, version = { "payment.status_changed": ("payment_id", "payment_version"), "refund.status_changed": ("refund_id", "refund_version"), "payout.status_changed": ("payout_id", "payout_version"), }.get(event["type"], ("event_id", None)) subject, version = event[subject], event.get(version, 0) if version else 0 # Events may come late or twice: an older version than the one applied changes nothing. if versions.get(subject, 0) > version: return versions[subject] = version print(f"verified {event['type']} {subject} v{version} {event.get('status', '')}", flush=True)
class Receiver(BaseHTTPRequestHandler): def do_POST(self): raw = self.rfile.read(int(self.headers.get("Content-Length", 0))) try: # The exact bytes received; the timestamp must be within 5 minutes. event = webhook.verify(raw, {name.lower(): value for name, value in self.headers.items()}) except WebhookVerificationError: self.send_response(400) self.end_headers() return if self.headers["webhook-id"] not in handled: handled.add(self.headers["webhook-id"]) apply(event) self.send_response(204) # answer 2xx within 15 seconds; do slow work after answering self.end_headers()
def log_message(self, *args): pass
HTTPServer(("", int(os.environ.get("PORT", "8080"))), Receiver).serve_forever()//go:build ignore
// Receive webhooks: verify each one with the Standard Webhooks library, act on it once.package main
import ( "encoding/json" "io" "log" "net/http" "os" "sync"
standardwebhooks "github.com/standard-webhooks/standard-webhooks/libraries/go")
type event struct { EventID string `json:"event_id"` Type string `json:"type"` Status string `json:"status"` PaymentID string `json:"payment_id"` PaymentVersion int `json:"payment_version"` RefundID string `json:"refund_id"` RefundVersion int `json:"refund_version"` PayoutID string `json:"payout_id"` PayoutVersion int `json:"payout_version"`}
func main() { // whsec_…, answered once by PUT /v1/webhook-endpoint. webhook, err := standardwebhooks.NewWebhook(os.Getenv("WEBHOOK_SECRET")) if err != nil { log.Fatal(err) } var mu sync.Mutex handled := map[string]bool{} // webhook-id of the events acted on; keep this in your database versions := map[string]int{} // the highest version applied, per payment, refund or payout
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { raw, err := io.ReadAll(r.Body) // the exact bytes received, before any JSON parsing if err != nil || webhook.Verify(raw, r.Header) != nil { // the timestamp must be within 5 minutes w.WriteHeader(http.StatusBadRequest) return } var e event if err := json.Unmarshal(raw, &e); err != nil { w.WriteHeader(http.StatusBadRequest) return } mu.Lock() if id := r.Header.Get("webhook-id"); !handled[id] { handled[id] = true apply(e, versions) } mu.Unlock() w.WriteHeader(http.StatusNoContent) // answer 2xx within 15 seconds; do slow work after answering }) port := os.Getenv("PORT") if port == "" { port = "8080" } log.Fatal(http.ListenAndServe(":"+port, nil))}
// apply acts on an event; an older version than the one applied changes nothing, as events// may come late or twice.func apply(e event, versions map[string]int) { subject, version := e.EventID, 0 switch e.Type { case "payment.status_changed": subject, version = e.PaymentID, e.PaymentVersion case "refund.status_changed": subject, version = e.RefundID, e.RefundVersion case "payout.status_changed": subject, version = e.PayoutID, e.PayoutVersion } if versions[subject] > version { return } versions[subject] = version log.Printf("verified %s %s v%d %s", e.Type, subject, version, e.Status)}More: receiving webhooks.