Developers
Packdoor API
Read your events, dates, tickets, orders and check-ins, check tickets in, and get signed webhooks when something happens. Version 1. Make keys and webhooks in the console: Settings, Developers.
Keys
Every request carries a key: Authorization: Bearer pd_live_…. Owners and admins make keys; each key belongs to one organization and reads only that organization. A key is shown once, when it is made; Packdoor keeps only a hash of it. A key stops working when it is revoked, or when the person who made it is no longer an owner or admin.
A read-only key reads. A key that can also make changes can use POST /v1/tickets/{id}/check-in, and the MCP's changes (on the Grow and Pro plans). Buyers' names and emails are left out (shown as null) unless the key was made to include them. Keys made on a test copy of Packdoor start with pd_test_.
Addresses
Base address: https://api.packdoor.ca/v1. Answers are JSON and never cached. Times are ISO 8601 in UTC; money is in cents.
GET /v1/me: the key and its organization.GET /v1/events,GET /v1/events/{id}: events with their dates in short. Filters: limit, cursor, status.GET /v1/dates,GET /v1/dates/{id}: dates; one date also lists its ticket types with what is left now (available, computed when you ask). Filters: limit, cursor, eventId, status.GET /v1/orders,GET /v1/orders/{id}: orders with their tickets and amounts. Filters: limit, cursor, eventId, dateId, status.GET /v1/tickets,GET /v1/tickets/{id}: attendees, whether each is checked in and inside. Filters: limit, cursor, eventId, dateId, orderId, status.GET /v1/checkins: every scan at the door (admitted, exited, duplicate, refused, undone). Filters: limit, cursor, dateId, ticketId.POST /v1/tickets/{id}/check-in: admits a ticket by the door's own rules (re-entry, void, refunded, already inside). The answer'sresultisadmitted,duplicateorrejectedwith areason.
curl https://api.packdoor.ca/v1/orders?status=paid&limit=50 \
-H "Authorization: Bearer pd_live_..."Pages
Lists are newest first: {"object": "list", "data": [...], "hasMore": true, "nextCursor": "..."}. Ask for the next page with ?cursor= and the nextCursor you got. limit is 1 to 100 (default 25). An unknown parameter is refused, so a typo never reads as "no filter".
Writes and retries
Every POST needs an Idempotency-Key header (16 to 100 letters, digits, - or _; a UUID is fine). Send the same key when you retry: you get the first answer again (with Idempotent-Replayed: true), never a second check-in.
curl -X POST https://api.packdoor.ca/v1/tickets/TICKET_ID/check-in \
-H "Authorization: Bearer pd_live_..." \
-H "Idempotency-Key: 6f1c2b3e-9a7d-4c1e-8f00-2b9d4c7a1e55"Limits and errors
Each key may make 120 requests a minute; past that the answer is 429 with Retry-After. Errors look like {"error": {"code": "not_found", "message": "..."}}. Another organization's ids are simply not_found.
401 unauthorized: Send your API key in the Authorization header: Bearer pd_live_...401 api_key_invalid: That API key is not known. Check that it was copied whole.401 api_key_revoked: That API key was revoked. Make a new one in the console: Settings, Developers.401 api_key_owner_gone: The person who made this API key is no longer an owner or admin of the organization. Make a new key.403 organization_suspended: This organization is suspended. Contact Packdoor.403 insufficient_scope: This API key can only read. Make a key that can also make changes (Settings, Developers).403 door_device_off: This API key's door phone was turned off on the Door phones page.404 not_found: Nothing here with that id.405 method_not_allowed: This address does not take that method.400 bad_request: The request is not valid.400 bad_filter: A query parameter is not valid.400 bad_cursor: That cursor is not one this API gave out.400 idempotency_key_required: Every POST needs an Idempotency-Key header (16 to 100 characters, a UUID is fine).400 bad_idempotency_key: The Idempotency-Key must be 16 to 100 letters, digits, dashes or underscores.422 idempotency_key_reused: This Idempotency-Key was already used for a different request.409 request_in_progress: A request with this Idempotency-Key is still running. Try again in a moment.429 rate_limited: Too many requests with this key. Wait a minute, then go on.500 server_error: Something went wrong at Packdoor. Try again; if it keeps happening, contact us.
Connecting an AI agent (MCP)
Claude, ChatGPT and other AI assistants can work with your events through Packdoor's MCP server (Model Context Protocol) at https://api.packdoor.ca/mcp. It is part of the Grow and Pro plans: on Start every tool answers plan_required (the REST API above works on every plan). The agent signs in with one of your API keys, the same way as the API, with the same per-key limit of 120 requests a minute, and sees only that key's organization.
In Claude Code:
claude mcp add --transport http packdoor https://api.packdoor.ca/mcp --header "Authorization: Bearer pd_live_..."Clients that take a JSON file with an http server (Claude Code's .mcp.json, Cursor and others):
{
"mcpServers": {
"packdoor": {
"type": "http",
"url": "https://api.packdoor.ca/mcp",
"headers": {
"Authorization": "Bearer pd_live_..."
}
}
}
}Claude Desktop, through the mcp-remote bridge (it needs Node.js), in claude_desktop_config.json:
{
"mcpServers": {
"packdoor": {
"command": "npx",
"args": [
"-y",
"mcp-remote",
"https://api.packdoor.ca/mcp",
"--header",
"Authorization:${PACKDOOR_AUTH}"
],
"env": {
"PACKDOOR_AUTH": "Bearer pd_live_..."
}
}
}
}The server is stateless: every message is a POST with the key, and answers are JSON. Use a read-only key if the agent should only look things up. Buyers' names and emails are left out unless the key includes them.
Tools
whoami: The organization and the API key this connection uses, with its scopes (read, write) and whether buyers' names and emails are included.list_brands: The organization's brands (with their cities and time zone) and venues. Use a brand's id to create an event and a venue's id when adding a date.list_events: The organization's events, newest first, one page at a time. Each has its status, brand and web address (slug).get_event: One event by id.list_dates: Event dates (occurrences), newest first, optionally of one event. Start, end, doors, time zone, venue, capacity.get_date: One date with its ticket types: price, quantity, sold and available now (computed, never stored).list_orders: Orders, newest first, optionally of one event or date or with one status. Totals in cents (CAD), refunds, tickets. Buyers' names and emails only if the key includes them.get_order: One order with its tickets and money (cents).list_attendees: Tickets issued, newest first, optionally of one event, date or order: holder, ticket type, status, checked in or not. Names and emails only if the key includes them.get_ticket: One ticket by id: status, ticket type, check-in state.list_checkins: Door scans, newest first, optionally of one date or ticket: result (admitted, duplicate, refused), direction, when.sales_summary: Orders, tickets, gross, refunded and net (cents, after refunds; test orders left out) over a time range, day by day, and the next date coming up. Default range: the last 30 days.sales_pacing: How one date is selling against comparable past dates: sold so far (online and at the door), days left, expected now, verdict (ahead, behind, on pace) and the forecast range of tickets and money, with each ticket type's sold and left.finance_summary: Payout-accurate revenue (cents): card sales gross, refunds, disputes, Packdoor fees, processing fees, net payout, tax and net; offline sales and the Packdoor fee owed on them; by event. Optionally one event or a date range.
Changes (keys that can make changes only):
create_draft_event: Create an event as a draft in one of the organization's brands (list_brands). It is not on the site until published; add a date and a ticket type first.add_date: Add a date to an event. Times are wall-clock times in the date's time zone, like 2026-10-24T21:00 in America/Toronto; the end must be after the start, and no two dates of an event may start at the same time.add_ticket_tier: Add a ticket type (tier) to an event: name, face price in cents (CAD), how many. Buyers see the all-in price (fees and tax added).publish_event: Publish a draft event, or reopen sales of a closed one: it goes on the organization's site and tickets can be bought (from the sales start, if set). It needs a date and a ticket type.add_guests: Add names (with plus-ones) to a guest list of one date. All or nothing; at most 500 at once.create_discount_code: Create a discount code for the whole organization, one brand or one event (optionally only some of its ticket types). value: basis points for percent (20% = 2000), cents otherwise. Times are wall-clock in timezone.check_in_ticket: Admit a ticket at the door, by the door's own rules (once only, re-entry, void and refunded tickets refused). The dry run says what the door would answer.
Every change is two calls
Called without confirmToken, a change is a dry run: Packdoor makes it and undoes it, so the answer is what would really happen (a taken web address or an event not ready to publish is refused here), with a preview in words and a confirmToken. Nothing has changed yet. The agent shows you the preview; the same call again with exactly the same arguments and the token applies it. A token is good for 10 minutes, for those arguments and that key only, and once: using it again gives the first answer back, never a second change. Every applied change is in the console's audit log with the key and the person who made it. Refunds, voids and payouts are not available through the MCP.
plan_required: The Packdoor MCP is part of the Grow and Pro plans, and this organization is on Start. An owner or admin can change the plan in the Packdoor console: Settings, Plan. The REST API works on every plan.confirm_token_mismatch: That confirmToken was not made for exactly these arguments with this key. Run the dry run again (without confirmToken) and use the token it gives.confirm_token_expired: That confirmToken has expired (they last 10 minutes). Run the dry run again.mcp_not_configured: Changes through the MCP can't be confirmed on this server yet. Contact Packdoor.bad_arguments: Some arguments are not valid.
Webhooks
Add an https address and pick events. Packdoor then sends a POST with a JSON body when one happens, usually within five minutes:
order.paid: an order was paid (online, or an unpaid order marked paid).data.objectis the order with its tickets.order.refunded: a refund went through.data.objectis the order;data.refundthe refund.order.cancelled: an order was cancelled.ticket.checked_in: a ticket was admitted at the door.data.objectis the ticket;data.checkinthe scan.event.published: an event was published.data.objectis the event.webhook.test: sent by "Send test event" in the console.
POST /your/address
Content-Type: application/json
Packdoor-Signature: t=1760000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Packdoor-Event-Id: 9b2c... (the same for every endpoint that gets this event)
Packdoor-Event-Type: order.paid
Packdoor-Delivery-Id: 41f0... (one per endpoint per event)
{"id": "9b2c...", "object": "event", "type": "order.paid", "apiVersion": "v1",
"createdAt": "2026-10-06T21:04:11.52+00:00", "organizationId": "...",
"data": {"object": {"object": "order", "id": "...", "status": "paid", ...}}}Answer with any 2xx within 10 seconds. Anything else (a 3xx too: redirects are not followed) is tried again 7 more times, waiting 5, 10, 20 minutes and so on, about 10 hours in all. An address that keeps failing for a day is turned off; turn it back on in the console. An event can arrive twice or out of order: use Packdoor-Event-Id to skip one you already have. Events: order.paid, order.refunded, order.cancelled, ticket.checked_in, event.published.
Packdoor sends only to public https addresses: never to a private, local or reserved address, checked again after every DNS lookup.
Checking the signature
Each address has its own signing secret (whsec_…), shown once when the address is added or when you make a new one. Packdoor-Signature holds t, the time of sending in seconds, and v1, the hex HMAC-SHA256 of t + "." + body keyed with the whole secret. Compute it over the raw body, compare in constant time, and refuse a t more than 5 minutes from your clock. During a secret change, accept either secret.
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: the request body exactly as received (a string, before JSON.parse).
export function fromPackdoor(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
const want = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return header.split(",").some((p) => p.startsWith("v1=") &&
p.length === 67 && timingSafeEqual(Buffer.from(p.slice(3)), Buffer.from(want)));
}