Setting up
- In Recharge, go to Integrations → Webhook and click Connect. You need to be an organisation admin or manager.
- Name the webhook and paste your HTTPS endpoint URL.
- Optionally choose authentication: a Bearer token (sent as
Authorization: Bearer …) or an API key in a header you name (defaultX-API-Key). Every request is signed either way. - Pick the events you want and a detail level, Minimal or Rich (see below), then save.
- Copy the signing secret shown after saving. Use the test panel to send a sample event and check your endpoint returns a 2xx.
An organisation can have up to five webhook endpoints, each with its own URL, secret and events.
Events
| Event | Sent when |
|---|---|
holiday.created | Someone requests leave (status pending). |
holiday.approved | A request is approved, or leave is booked that needs no approval. |
holiday.cancelled | Leave is cancelled, or a request is declined (then status is rejected). |
absence.approved | An absence is recorded. |
absence.cancelled | An absence is removed. |
Holiday events are only sent for holiday leave. Other leave types, and anyone’s notes or reasons, never leave Recharge. Absence events say that someone is away, never why.
Payload
Every event has the same envelope: eventType, a unique eventId, an ISO 8601 timestamp, version (currently "1.0") and a data object. Dates in data are YYYY-MM-DD and inclusive: a one-day holiday has the same start and end date.
Minimal
Who and when. Use this when the receiving system doesn’t need allowance figures.
{
"eventType": "holiday.approved",
"eventId": "holiday.approved-1767225600000-9f2c4a1be03d7756",
"timestamp": "2026-10-05T09:12:44.512Z",
"version": "1.0",
"data": {
"holidayId": 4182,
"userId": "user_2abc123",
"userName": "Sam Taylor",
"startDate": "2026-10-19",
"endDate": "2026-10-21",
"status": "approved"
}
}Rich
Adds half-day slots, the person’s allowance after this booking, and the organisation. startTimeSlot and endTimeSlot are full_day, am or pm: a holiday ending with am finishes at midday on its last day.
{
"eventType": "holiday.approved",
"eventId": "holiday.approved-1767225600000-9f2c4a1be03d7756",
"timestamp": "2026-10-05T09:12:44.512Z",
"version": "1.0",
"data": {
"holidayId": 4182,
"userId": "user_2abc123",
"userName": "Sam Taylor",
"startDate": "2026-10-19",
"endDate": "2026-10-21",
"status": "approved",
"startTimeSlot": "full_day",
"endTimeSlot": "am",
"daysUsed": 8.5,
"remainingAllowance": 16.5,
"orgId": "org_2xyz789",
"orgName": "Acme Ltd"
}
}Absences
Absence events carry absenceId instead of holidayId, and never include allowance figures. Rich adds the organisation.
{
"eventType": "absence.approved",
"eventId": "absence.approved-1767225600000-41d0e8c27a95b3f1",
"timestamp": "2026-10-05T09:15:02.104Z",
"version": "1.0",
"data": {
"absenceId": 917,
"userId": "user_2abc123",
"userName": "Sam Taylor",
"startDate": "2026-10-06",
"endDate": "2026-10-06",
"status": "approved",
"orgId": "org_2xyz789",
"orgName": "Acme Ltd"
}
}| Field | Type | Notes |
|---|---|---|
holidayId | number | Stable ID of the leave booking. Use it to update or remove your copy. |
absenceId | number | Absence events only. |
userId | string | Stable Recharge user ID. |
userName | string | Display name. |
status | string | Holidays: pending, approved, cancelled, or rejected for a declined request. Absences: approved or cancelled. |
daysUsed | number | Rich only. Days of holiday the person has booked, including this one. Half days count as 0.5. |
remainingAllowance | number | Rich only. Their allowance minus daysUsed. Can be negative. |
orgId | string | Rich only. Empty for personal accounts. |
Headers
POST /your/endpoint HTTP/1.1
Content-Type: application/json
X-Webhook-Timestamp: 1791198764
X-Webhook-Signature: v0=5d1c0b7e3f...a94e
Authorization: Bearer <your token> (only if you chose Bearer authentication)X-Webhook-Timestamp: Unix time in seconds when the request was signed.X-Webhook-Signature:v0=followed by the hex HMAC-SHA256 ofv0:<timestamp>:<raw body>, keyed with your signing secret.X-Webhook-Signature-Previous: sent for 24 hours after you rotate the secret, signed with the old one, so you can switch secrets without dropping events.
Verifying signatures
Check every request before trusting it: rebuild the signature from the raw request body and compare it in constant time. Always use the raw bytes. Parsing the JSON and serialising it again changes the bytes and the signature won’t match. Accept either signature header so a secret rotation doesn’t break anything.
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.RECHARGE_WEBHOOK_SECRET;
function isValid(rawBody, timestamp, signature, secret) {
// Reject anything older than 5 minutes to stop replays.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected =
"v0=" + crypto.createHmac("sha256", secret).update(`v0:${timestamp}:${rawBody}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature ?? "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Use the raw body: re-serialising parsed JSON changes the bytes and breaks the signature.
app.post("/webhooks/recharge", express.raw({ type: "application/json" }), (req, res) => {
const rawBody = req.body.toString("utf8");
const timestamp = req.get("X-Webhook-Timestamp");
const valid =
isValid(rawBody, timestamp, req.get("X-Webhook-Signature"), SECRET) ||
isValid(rawBody, timestamp, req.get("X-Webhook-Signature-Previous"), SECRET);
if (!valid) return res.sendStatus(401);
const event = JSON.parse(rawBody);
// event.eventId is the same on every retry: skip it if you've already handled it.
console.log(event.eventType, event.data);
res.sendStatus(200);
});
app.listen(3000);import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["RECHARGE_WEBHOOK_SECRET"]
def is_valid(raw_body: bytes, timestamp: str, signature: str | None, secret: str) -> bool:
# Reject anything older than 5 minutes to stop replays.
if not signature or not timestamp or abs(time.time() - int(timestamp)) > 300:
return False
base = b"v0:" + timestamp.encode() + b":" + raw_body
expected = "v0=" + hmac.new(secret.encode(), base, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
@app.post("/webhooks/recharge")
def recharge_webhook():
raw_body = request.get_data() # the exact bytes Recharge signed
timestamp = request.headers.get("X-Webhook-Timestamp", "")
valid = is_valid(raw_body, timestamp, request.headers.get("X-Webhook-Signature"), SECRET) or is_valid(
raw_body, timestamp, request.headers.get("X-Webhook-Signature-Previous"), SECRET
)
if not valid:
abort(401)
event = json.loads(raw_body)
# event["eventId"] is the same on every retry: skip it if you've already handled it.
print(event["eventType"], event["data"])
return "", 200Retries and responses
- Any 2xx response counts as delivered. Respond within 30 seconds; do slow work afterwards.
- Redirects aren’t followed, and endpoints must be public HTTPS addresses.
- A failed delivery is retried about a minute later, then up to twice more over the following day. Retries carry the same
eventId, so use it to ignore duplicates. - Every attempt, with its status code and response, is in the delivery log in Recharge, where you can also resend an event by hand.
- Events can arrive out of order. Use
timestampandstatusto decide the latest state.
Questions or a missing field? Get in touch.

