Skip to main content
Hobbstack

Developers / Webhooks

Know the moment it happens

Thirteen signed event types, delivered to an https endpoint you control. Every one of them fires from a real code path in the running product — none is declared and never sent.

Setup

Register an endpoint

  1. 1. Sign in at hobbstack.app and open Org Settings. You need the organization-settings permission.
  2. 2. Add a webhook with a name, an https:// URL, and the events you want. Plain http:// is refused — event payloads carry operational data and are not sent in the clear.
  3. 3. Copy the secret. It is 64 hexadecimal characters and it is shown once; afterwards the screen shows only its first eight characters so you can tell subscriptions apart.
  4. 4. Press Test. That delivers an event named test.ping with the body {"message": "Hobbstack webhook test"}, signed exactly like a real one — the fastest way to prove your verification code works. It is sent to the subscription you chose whatever its event list says, and test fires are limited to ten per hour.

Subscribe to individual event names, or to everything

The selector accepts any of the thirteen names below, or * for all of them. Prefix wildcards such as squawk.* are not accepted when creating a subscription today — they are rejected with UNKNOWN_EVENT. List the names you want, or use * and filter on your side.

Delivery

What arrives, and how

A delivery, in full
POST /your-endpoint HTTP/1.1
Content-Type: application/json
X-Hobbstack-Event: squawk.created
X-Hobbstack-Signature: sha256=6f1b...c92a
User-Agent: Hobbstack-Webhooks/1.0

{
  "event": "squawk.created",
  "delivered_at": "2026-08-04T18:22:11.204871+00:00",
  "data": {
    "id": "9c8b7a65-4321-4fed-8cba-0987654321fe",
    "aircraft_id": "b1c2d3e4-f5a6-4718-8293-a1b2c3d4e5f6",
    "description": "Left main tire worn to cord on outboard shoulder",
    "severity": "grounding",
    "is_grounding": true,
    "reported_by": "11111111-2222-4333-8444-555555555555",
    "created_at": "2026-08-04T18:22:10+00:00"
  }
}

Headers

HeaderValue
Content-Typeapplication/json
X-Hobbstack-EventThe event name, matching the envelope’s event field.
X-Hobbstack-Signaturesha256= followed by the hex HMAC-SHA256 of the raw request body, keyed with your subscription secret.
User-AgentHobbstack-Webhooks/1.0

Retries and failure

  • Success is any 2xx. Every other status counts as a failed attempt, including redirects — we deliberately do not follow them, so a 301 to your new URL is a failure, not a hop. Update the subscription instead.
  • Three attempts, each with a 10-second timeout, waiting 2 seconds after the first and 8 seconds after the second.
  • After the third failure the event is dropped. There is no dead-letter queue and no replay endpoint today. If an event matters to your books, reconcile against the REST API rather than trusting the callback to be the only copy.
  • Five consecutive failed deliveries disable the subscription and it stops receiving anything. Bring it back with Enable on the subscription in Org Settings → Webhooks: it resumes delivery and resets the failure counter, and the signing secret is unchanged, so your receiver needs no redeploy. The control is POST /api/orgs/{org_id}/webhooks/{id}/enable, gated on the org.settings capability — it uses your signed-in session, not an API key, because the hbs_pk_ REST API is read-only. Events that fired while the subscription was disabled are not replayed; backfill those from the REST API.
  • A successful Test does not revive a disabled subscription. Test resets the failure counter to zero but leaves the subscription disabled, so a dead subscription can sit at zero failures while still receiving nothing. Trust the enabled/disabled status, not the count — and use Enable, not Test, to bring one back.
  • Deliveries are not ordered. Subscribers are fanned out in parallel and retries stagger independently, so use the timestamps inside the payload rather than arrival order.
  • Answer fast. Acknowledge with 2xx and do your work afterwards; a receiver that processes before replying will hit the 10-second timeout and be retried, giving you the same event twice.

Some events arrive in pairs

A confirmed booking fires reservation.created and reservation.confirmed with identical payloads, and a completed flight fires reservation.completed and flight_session.completed, also identical. Subscribe to one of each pair, or de-duplicate on the id.

Security

Verify every delivery

Anyone can POST to a public URL. The signature is what makes a delivery trustworthy, so verify it before you act on the payload and reject anything that fails. Two rules matter more than the rest: hash the raw bytes you received, never a re-serialised copy of the parsed JSON, and compare in constant time.

Python (Flask)
python
import hashlib
import hmac
import os

from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["HOBBSTACK_WEBHOOK_SECRET"]  # shown once, at creation


@app.post("/hobbstack-webhook")
def receive():
    # Sign the RAW bytes. Hashing json.dumps(request.json) will not match:
    # key order and separators differ from what we sent.
    expected = "sha256=" + hmac.new(
        SECRET.encode(), request.get_data(), hashlib.sha256
    ).hexdigest()
    sent = request.headers.get("X-Hobbstack-Signature", "")

    # Compare BYTES. compare_digest() raises TypeError on str operands that
    # are not both ASCII-only, and Flask decodes header values as latin-1 —
    # so a hostile header carrying any byte >= 0x80 would turn a 401 into an
    # unhandled 500. errors="replace" keeps the encode itself total.
    if not hmac.compare_digest(sent.encode("utf-8", "replace"), expected.encode()):
        abort(401)

    envelope = request.get_json()
    event = envelope["event"]            # also in the X-Hobbstack-Event header
    data = envelope["data"]

    handle(event, data)

    # Answer 2xx quickly. Anything else counts as a failed attempt, and
    # five consecutive failures disable the subscription.
    return "", 200
Node (Express)
javascript
import crypto from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.HOBBSTACK_WEBHOOK_SECRET; // shown once, at creation

// express.raw keeps req.body as a Buffer. express.json would give you a
// parsed object, and re-serialising it produces different bytes than the
// ones we signed, so every delivery would fail verification.
app.post(
  "/hobbstack-webhook",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const expected =
      "sha256=" +
      crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");
    const sent = req.get("X-Hobbstack-Signature") ?? "";

    const a = Buffer.from(sent);
    const b = Buffer.from(expected);
    // timingSafeEqual throws on a length mismatch, so check length first.
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.sendStatus(401);
    }

    const envelope = JSON.parse(req.body.toString("utf8"));
    handle(envelope.event, envelope.data);

    // Answer 2xx quickly. Anything else counts as a failed attempt, and
    // five consecutive failures disable the subscription.
    res.sendStatus(200);
  },
);

If you rotate a secret, delete the subscription and create a new one — there is no in-place secret rotation, so plan for a brief window where you accept either secret if you cannot tolerate a gap.

Endpoint URLs

Why a URL gets refused

Webhook URLs are checked before they are saved and again before every single delivery, so a subscription created before the check existed still cannot be used to make our servers reach somewhere they should not. The check has five distinct outcomes, and the error you get names which one — a single generic “must be a public https endpoint” would be correct for one of them and misleading for the other four.

WEBHOOK_URL_HTTPS_REQUIRED

Webhook URLs must start with https:// — Hobbstack will not send event payloads over plain http.

Serve the receiver over TLS. There is no http:// escape hatch, including for local development — use a tunnelling service that terminates TLS on a public hostname.

WEBHOOK_URL_HOST_REQUIRED

That URL has no hostname. Include the full endpoint, e.g. https://hooks.example.com/hobbstack.

You submitted something the parser accepted but that has no host component — usually a missing slash after the scheme.

WEBHOOK_URL_PARSE_FAILED

That URL could not be parsed. Include the full endpoint, e.g. https://hooks.example.com/hobbstack.

The string is not a URL at all. Paste the complete address including the scheme.

WEBHOOK_URL_DNS_FAILED

Hobbstack could not look up that hostname. Check the spelling, or that the domain is publicly resolvable.

The hostname did not resolve from our network. Internal-only DNS names fail here by design — the receiver has to be reachable from the public internet.

WEBHOOK_URL_PRIVATE_IP

Webhook URL must be a public https endpoint — not localhost, an internal IP, or a private network name.

The host is, or resolves to, an address that is not globally routable: loopback (127.0.0.0/8, ::1), RFC 1918 private ranges, link-local (including the 169.254.169.254 cloud metadata address), carrier-grade NAT (100.64.0.0/10), multicast, or reserved space. Every resolved address is checked, so a hostname that resolves to even one private address is refused.

Local development needs a public hostname

There is no allowance for localhost, a private LAN address or an internal DNS name, and none is coming — that check is what stops a webhook URL being used to make our servers probe their own network. Point a tunnelling service at your machine and register its public https hostname.

Catalogue

All 13 events

Each payload below is the data object of the envelope — the event and delivered_at keys wrap it. Fields are shown exactly as the emitting code builds them.

squawk.created

A member or instructor reports a discrepancy against an aircraft.

json
{
  "id": "9c8b7a65-4321-4fed-8cba-0987654321fe",
  "aircraft_id": "b1c2d3e4-f5a6-4718-8293-a1b2c3d4e5f6",
  "description": "Left main tire worn to cord on outboard shoulder",
  "severity": "grounding",
  "is_grounding": true,
  "reported_by": "11111111-2222-4333-8444-555555555555",
  "created_at": "2026-08-04T18:22:10+00:00"
}

squawk.resolved

A squawk moves into the resolved status from anything else.

json
{
  "id": "9c8b7a65-4321-4fed-8cba-0987654321fe",
  "aircraft_id": "b1c2d3e4-f5a6-4718-8293-a1b2c3d4e5f6",
  "description": "Left main tire worn to cord on outboard shoulder",
  "severity": "grounding",
  "is_grounding": true,
  "resolved_by": "22222222-3333-4444-8555-666666666666",
  "resolved_at": "2026-08-05T15:07:44+00:00"
}

squawk.escalated

An existing squawk is escalated to grounding severity.

severity is always the literal "grounding" on this event — it is what the escalation sets.

json
{
  "id": "9c8b7a65-4321-4fed-8cba-0987654321fe",
  "aircraft_id": "b1c2d3e4-f5a6-4718-8293-a1b2c3d4e5f6",
  "description": "Left main tire worn to cord on outboard shoulder",
  "severity": "grounding"
}

reservation.created

Any reservation is created, whatever state it lands in.

json
{
  "id": "6f2f1c9e-6a3b-4a1c-9a6a-2b1f3c4d5e6f",
  "aircraft_id": "b1c2d3e4-f5a6-4718-8293-a1b2c3d4e5f6",
  "pilot_id": "11111111-2222-4333-8444-555555555555",
  "instructor_id": null,
  "start_time": "2026-08-06T14:00:00+00:00",
  "end_time": "2026-08-06T16:00:00+00:00",
  "activity_type": "solo",
  "status": "confirmed",
  "is_backup": false
}

reservation.confirmed

A newly created reservation lands in the confirmed state — the common case. It fires alongside reservation.created, not instead of it, with an identical payload.

A subscriber to both will receive two deliveries for one booking. De-duplicate on the reservation id if that matters to you.

json
{
  "id": "6f2f1c9e-6a3b-4a1c-9a6a-2b1f3c4d5e6f",
  "aircraft_id": "b1c2d3e4-f5a6-4718-8293-a1b2c3d4e5f6",
  "pilot_id": "11111111-2222-4333-8444-555555555555",
  "instructor_id": null,
  "start_time": "2026-08-06T14:00:00+00:00",
  "end_time": "2026-08-06T16:00:00+00:00",
  "activity_type": "solo",
  "status": "confirmed",
  "is_backup": false
}

reservation.cancelled

A reservation is cancelled by a member or by staff.

No status field on this event — cancellation is the event.

json
{
  "id": "6f2f1c9e-6a3b-4a1c-9a6a-2b1f3c4d5e6f",
  "aircraft_id": "b1c2d3e4-f5a6-4718-8293-a1b2c3d4e5f6",
  "pilot_id": "11111111-2222-4333-8444-555555555555",
  "instructor_id": null,
  "start_time": "2026-08-06T14:00:00+00:00",
  "end_time": "2026-08-06T16:00:00+00:00",
  "activity_type": "solo",
  "cancellation_reason": "Weather below personal minimums"
}

reservation.checked_in

A pilot completes the pre-flight check-in for a reservation.

forced is true when the check-in overrode a blocking issue — a useful thing to alert on.

json
{
  "id": "6f2f1c9e-6a3b-4a1c-9a6a-2b1f3c4d5e6f",
  "aircraft_id": "b1c2d3e4-f5a6-4718-8293-a1b2c3d4e5f6",
  "pilot_id": "11111111-2222-4333-8444-555555555555",
  "instructor_id": null,
  "checked_in_at": "2026-08-06T13:52:07+00:00",
  "forced": false
}

reservation.completed

A pilot completes checkout at the end of the flight.

json
{
  "id": "6f2f1c9e-6a3b-4a1c-9a6a-2b1f3c4d5e6f",
  "aircraft_id": "b1c2d3e4-f5a6-4718-8293-a1b2c3d4e5f6",
  "pilot_id": "11111111-2222-4333-8444-555555555555",
  "instructor_id": null,
  "checkout_completed_at": "2026-08-06T16:11:38+00:00",
  "hobbs_out": 4821.6,
  "hobbs_in": 4823.4
}

flight_session.completed

The same checkout. It fires immediately after reservation.completed with a byte-identical payload, for integrations that model flights rather than bookings.

Subscribe to one or the other, not both, unless you want two deliveries per flight.

json
{
  "id": "6f2f1c9e-6a3b-4a1c-9a6a-2b1f3c4d5e6f",
  "aircraft_id": "b1c2d3e4-f5a6-4718-8293-a1b2c3d4e5f6",
  "pilot_id": "11111111-2222-4333-8444-555555555555",
  "instructor_id": null,
  "checkout_completed_at": "2026-08-06T16:11:38+00:00",
  "hobbs_out": 4821.6,
  "hobbs_in": 4823.4
}

aircraft.grounded

An aircraft is taken out of service. Two different code paths fire it, and they send different fields — branch on reason, and treat every field except id and reason as optional.

json
// reason "admin_deactivate" — an admin deactivated the aircraft
{
  "id": "b1c2d3e4-f5a6-4718-8293-a1b2c3d4e5f6",
  "tail_number": "N172SP",
  "aircraft_type": "C172S",
  "is_active": false,
  "reason": "admin_deactivate"
}

// reason "mel_expired" — a deferred MEL item ran out of time and the
// scheduled job grounded the aircraft automatically
{
  "id": "b1c2d3e4-f5a6-4718-8293-a1b2c3d4e5f6",
  "tail_number": "N172SP",
  "reason": "mel_expired"
}

aircraft.reactivated

An aircraft is returned to service.

No reason field — reactivation has only one cause.

json
{
  "id": "b1c2d3e4-f5a6-4718-8293-a1b2c3d4e5f6",
  "tail_number": "N172SP",
  "aircraft_type": "C172S",
  "is_active": true
}

mel.created

A maintenance item is deferred onto the Minimum Equipment List.

mel_category is the FAA MEL letter (a, b, c or d) that sets the clear-by clock.

json
{
  "id": "5a5a5a5a-6666-4777-8888-999999999999",
  "aircraft_id": "b1c2d3e4-f5a6-4718-8293-a1b2c3d4e5f6",
  "item_description": "Landing light inoperative",
  "mel_category": "c",
  "deferral_date": "2026-08-05",
  "due_date": "2026-08-15"
}

mel.expired

A scheduled job finds a deferred MEL item past its due date. If that grounds the aircraft, a separate aircraft.grounded fires straight after with reason "mel_expired".

json
{
  "id": "5a5a5a5a-6666-4777-8888-999999999999",
  "aircraft_id": "b1c2d3e4-f5a6-4718-8293-a1b2c3d4e5f6",
  "item_description": "Landing light inoperative",
  "mel_category": "c",
  "due_date": "2026-08-15",
  "auto_grounded": true
}

Need an event that is not here?

The catalogue grows from what integrators ask for. Tell us what you are building and which moment you need to hear about — support@hobbstack.com.