Developer docs

Webhooks

Recharge can POST a signed JSON event to your endpoint whenever leave is requested, approved or cancelled. Use it to feed HR systems, payroll exports and internal dashboards, or point it at a catch-webhook step in Zapier, Make or n8n.

Setting up

  1. In Recharge, go to Integrations → Webhook and click Connect. You need to be an organisation admin or manager.
  2. Name the webhook and paste your HTTPS endpoint URL.
  3. Optionally choose authentication: a Bearer token (sent as Authorization: Bearer …) or an API key in a header you name (default X-API-Key). Every request is signed either way.
  4. Pick the events you want and a detail level, Minimal or Rich (see below), then save.
  5. 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

EventSent when
holiday.createdSomeone requests leave (status pending).
holiday.approvedA request is approved, or leave is booked that needs no approval.
holiday.cancelledLeave is cancelled, or a request is declined (then status is rejected).
absence.approvedAn absence is recorded.
absence.cancelledAn 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.

holiday.approved, Minimal
{
  "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.

holiday.approved, Rich
{
  "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.

absence.approved, Rich
{
  "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"
  }
}
FieldTypeNotes
holidayIdnumberStable ID of the leave booking. Use it to update or remove your copy.
absenceIdnumberAbsence events only.
userIdstringStable Recharge user ID.
userNamestringDisplay name.
statusstringHolidays: pending, approved, cancelled, or rejected for a declined request. Absences: approved or cancelled.
daysUsednumberRich only. Days of holiday the person has booked, including this one. Half days count as 0.5.
remainingAllowancenumberRich only. Their allowance minus daysUsed. Can be negative.
orgIdstringRich 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 of v0:<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.

Node.js (Express)
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);
Python (Flask)
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 "", 200

Retries 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 timestamp and status to decide the latest state.

Questions or a missing field? Get in touch.