Skip to content

Webhooks

Last updated October 12, 2026

A business using Tendroom can send what happens in its portal (a new client, a booking, a paid invoice) to an address of its own, as it happens. This page is for whoever builds that address. The business adds it in its portal under Settings → Connected apps, where it also finds the signing secret and a log of every delivery.

What Tendroom sends

Each event is one POST to the address, with a JSON body and these headers. They follow Standard Webhooks, so its libraries can check them.

  • webhook-id: the event's id. It's the same on every try of the same event.
  • webhook-timestamp: when this try was sent, in seconds since 1970 (UTC).
  • webhook-signature: v1, and the signature (below).
  • content-type: application/json and user-agent: Tendroom-Webhooks/1.
{
  "id": "evt_01JAB3XK9Q6T8VZ5M2N4P7R0WD",
  "type": "invoice.paid",
  "occurredAt": "2026-10-17T15:04:05.000Z",
  "business": { "id": "tnt_01J9…", "name": "Golden Hour Studio" },
  "data": {
    "id": "inv_01JA…",
    "number": "GH-0042",
    "status": "paid",
    "issueDate": "2026-10-01",
    "dueDate": "2026-10-15",
    "totalCents": 42500, "total": "425.00",
    "paidCents": 42500, "paid": "425.00",
    "balanceCents": 0, "balance": "0.00",
    "currency": "usd",
    "client": { "id": "cli_01J…", "name": "Ana Ruiz", "email": "ana@example.com" },
    "url": "https://golden.tendroom.com/portal/invoices/inv_01JA…"
  }
}

Money is in whole cents and as text, in US dollars. Dates are the business's own calendar dates and times its local time. url is the page in the portal, which needs signing in. Events never include a client's private link or private notes.

Events

The business chooses which of these its address gets, or all of them (including any added later).

  • client.createdA client is added
  • client.updatedA client's details change
  • inquiry.receivedAn inquiry comes in
  • booking.createdA booking is made
  • booking.movedA booking is moved
  • booking.cancelledA booking is cancelled
  • estimate.acceptedAn estimate is accepted
  • estimate.declinedAn estimate is declined
  • invoice.sentAn invoice is sent
  • payment.receivedA payment comes in, deposits too
  • invoice.paidAn invoice is paid in full
  • payment.refundedA payment is refunded
  • invoice.voidedAn invoice is voided
  • task.createdA task is added
  • task.completedA task is done
  • time.submittedHours are submitted
  • time.approvedHours are approved
  • pingA test event, sent when the business presses Send test event

Checking the signature

Check every request before trusting it: anyone can send a POST to your address. With a Standard Webhooks library (Node shown; there are others for most languages):

import { Webhook } from "standardwebhooks";

const webhook = new Webhook(process.env.TENDROOM_WEBHOOK_SECRET); // whsec_…

export async function handle(request) {
  const body = await request.text(); // the raw body, before any JSON parsing
  try {
    const event = webhook.verify(body, Object.fromEntries(request.headers));
    // event.id is the same on every try: skip one you've already handled.
    return new Response(null, { status: 204 });
  } catch {
    return new Response("Bad signature", { status: 400 });
  }
}

Or by hand:

signed  = webhook-id + "." + webhook-timestamp + "." + raw body
key     = base64-decode(the secret without its "whsec_" prefix)
expected = base64(HMAC-SHA256(key, signed))

webhook-signature is "v1,<signature>", or several of them separated by
spaces while a secret is being changed: accept the request if any one
matches expected (compare in constant time), and refuse it if
webhook-timestamp is more than 5 minutes from now.

When the business changes the secret, the old one keeps signing for 24 hours alongside the new one, so you can switch over without missing anything.

Answering, retries and pausing

  • Answer with any 2xx status within 5 seconds. The response body is ignored, so do slow work after answering.
  • Anything else (another status, a redirect, a timeout) is tried again: 8 tries in all, after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and a day. A 429 with Retry-After waits that long.
  • Redirects aren't followed: use the final address.
  • The same event can arrive more than once, and events can arrive out of order. Use the event id to skip repeats, and occurredAt (or fetch the latest from your own records) for order.
  • If 20 tries in a row to an address fail over at least an hour, or every try fails for 3 days, Tendroom pauses it and emails the business's owners. Nothing more is sent until they resume it; what was waiting is kept and sent then.
  • A 429 isn't counted as failing: Tendroom waits as long as its Retry-After asks.
  • The business can send any delivery from the last 7 days again from its log.

Questions

Write to privacy@tendroom.com.