Overview#
The Creve API is a REST API: you send an HTTPS request, and it answers with JSON. It reads the events, orders, attendees and payouts of one Creve account. Webhooks go the other way: Creve sends a message to your server when something happens, so you don't have to keep asking.
- Address
- https://creve-server.vercel.app/v1
- Version
- 2026-09-26
- Format
- JSON, over HTTPS only
This first version reads data; it doesn't change anything. It covers events and ticket types, orders, attendees with their check-ins, and payouts. Selling tickets, checking people in and editing events stay in Creve for now.
Orders and attendees include people's names, contact details and answers. Keep them secure, and use them only in the ways your privacy notice tells your attendees.
Keys and permissions#
Every request needs an API key, sent in the Authorization header. Make keys in your Creve dashboard under Settings > Developers. Each key is shown once, when you make it; Creve keeps only a scrambled copy.
curl https://creve-server.vercel.app/v1/events?limit=2 \
-H "Authorization: Bearer crv_test_YOUR_KEY"crv_live_…keys read your real records.crv_test_…keys read the sandbox.- Keep keys on your server. Never put one in a web page, a mobile app or a public code repository.
- Browsers can't call the API: it sends no CORS headers, so a key placed in a web page fails rather than works for anyone who copies it.
- If a key may have leaked, revoke it in the dashboard; it stops working at once. Then make a new one.
- Creve emails the account owner whenever a live key is made, and whenever a live webhook endpoint is added, moved to a new address or given a new signing secret.
Permissions
Each key can read only what you chose when you made it. Give each system just what it needs.
| Permission | Lets the key read | |
|---|---|---|
events:read | Events and ticket types | |
registrations:read | Orders and attendees, with their contact details and answers | |
payouts:read | Payouts |
Sandbox#
A test key reads sample data instead of your account: three events (one of them already over), about thirty orders including a refund and a part refund, attendees with check-ins, and three payouts (paid, processing and failed). Everything in it says "livemode": false. Nothing you do with a test key touches your real records.
The sample data is the same every day for your account, ids included, so you can write tests against it. The people in it are made up, with email addresses at example.com.
To try webhooks, add a test endpoint. Test endpoints never receive real activity; they get the samples you send, from the dashboard or with this request:
curl -X POST https://creve-server.vercel.app/v1/test_events \
-H "Authorization: Bearer crv_test_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"type": "checkin.created"}'Requests and responses#
- Field names are in snake_case. Every object has an
idand says what it is inobject. - Times are in ISO 8601, in UTC, such as
2026-10-26T06:00:00.000Z. Where a parameter takes a date, send ISO 8601 or Unix time in seconds. - Money is in the currency's main unit: KES 2,500 is
2500. (Paystack uses cents; Creve doesn't.) - A field with nothing in it is
null, not left out. - A parameter the request doesn't take is an error, so a misspelling can't quietly return everything.
Lists and pages#
A list comes back as { "object": "list", "data": [...], "has_more": true }, newest first. For the next page, send the last item's id as starting_after. When has_more is false, you have them all.
{
"object": "list",
"url": "/v1/events",
"has_more": true,
"data": [
{
"id": "65f0c2a1b4d3e8f9a0b1c2d3",
"object": "event",
"livemode": false,
"name": "Sandbox: Nairobi Tech Summit",
"slug": "sandbox-nairobi-tech-summit",
"status": "published",
"format": "in_person",
"starts_at": "2026-10-26T06:00:00.000Z",
"timezone": "Africa/Nairobi",
"ticket_types": [
{ "id": "…", "object": "ticket_type", "name": "Early bird", "price": 2500,
"currency": "KES", "quantity": 200, "sold": 7, "available": 193, "…": "…" }
],
"…": "…"
}
]
}Errors#
A request that fails gets an HTTP status of 400 or above and a body saying why. type is the broad kind, code the exact reason, and param the parameter at fault, when there is one. Quote request_id if you ask Creve support about a request.
{
"error": {
"type": "invalid_request_error",
"code": "parameter_invalid",
"message": "status must be one of: awaiting_payment, pending_approval, rejected, paid, …",
"param": "status",
"doc_url": "https://creve.co/developers#errors",
"request_id": "8f0b6c1e-…"
}
}| Status | code | Means |
|---|---|---|
| 400 invalid_request_error | parameter_invalid | A parameter's value isn't allowed. param says which. |
parameter_unknown | A parameter this request doesn't take, often a misspelling. | |
body_invalid | The body isn't valid JSON. | |
version_unsupported | The Creve-Version header names a version that doesn't exist. | |
| 401 authentication_error | api_key_missing | No Authorization header. |
api_key_invalid | The key isn't one Creve made, or was copied wrongly. | |
api_key_revoked | The key was revoked. | |
| 403 permission_error | scope_missing | The key doesn't have the permission this request needs. |
api_key_disabled | Creve has switched the key off. | |
test_mode_only | The request is only for test keys. | |
| 404 invalid_request_error | resource_missing | There's no such event, order or payout on your account. |
route_not_found | The API has no such request. | |
| 413 invalid_request_error | body_too_large | The body is over 5 MB. |
| 429 rate_limit_error | rate_limited | Too many requests this minute. Wait the seconds in Retry-After. |
too_many_failed_keys | Too many requests with a missing or wrong key from your address. Wait 15 minutes. | |
| 500 api_error | internal_error | Something went wrong at Creve. Try again; if it keeps happening, tell us the request_id. |
| 503 api_error | api_disabled | Creve has switched the API off for a while. Try again later. |
Request limits#
Each account can make 120 live requests and 60 test requestsa minute. All of an account's keys of one kind share that allowance, so making more keys doesn't raise it.
- Every answer has a
RateLimitheader, such aslimit=120, remaining=87, reset=41: requests left, and seconds until the count starts again. - Over the limit, you get
429with aRetry-Afterheader in seconds. Wait that long, then carry on. - After 30 requests with a missing or wrong key from one address within 15 minutes, that address is refused for the rest of the 15 minutes.
Versions#
The current version is 2026-09-26. Every answer says which version produced it, in the Creve-Versionheader. You can send the same header to state the version your code was written for; a version that doesn't exist is refused.
New fields, new notification types and new optional parameters can be added without a new version, so write your code to ignore fields it doesn't know. A change that could break working code will come as a new version with a new date.
Questions or something missing? Contact Creve support from your dashboard.
Requests#
All requests go to https://creve-server.vercel.app/v1. The permission is what the key needs; see Objects for every field returned.
| Request | Permission |
|---|---|
GET/v1/account | Any key |
GET/v1/events | events:read |
GET/v1/events/{id} | events:read |
GET/v1/orders | registrations:read |
GET/v1/orders/{id} | registrations:read |
GET/v1/attendees | registrations:read |
GET/v1/payouts | payouts:read |
GET/v1/payouts/{id} | payouts:read |
POST/v1/test_events | A test key |
GET/v1/account#
Which account the key belongs to, whether it's live or test, and its request limit. A good first request.
- Needs
Any key- Returns
- An Account.
GET/v1/events#
Your events, newest first, each with its ticket types and how many have sold.
- Needs
events:read- Returns
- A list of Events.
| Parameter | Type | |
|---|---|---|
limit | 1–100 | How many to return. 25 if left out. |
starting_after | id | The id of the last item on the page before, to get the next page. |
status | string | One of draft, published, ended, cancelled, suspended. |
starts_after | date | Only events starting at or after this time. |
starts_before | date | Only events starting at or before this time. |
GET/v1/events/{id}#
One event, by its id or by the slug in its web address.
- Needs
events:read- Returns
- An Event.
GET/v1/orders#
Orders on your events, newest first. An order is one purchase or registration, for one or more people.
- Needs
registrations:read- Returns
- A list of Orders.
| Parameter | Type | |
|---|---|---|
limit | 1–100 | How many to return. 25 if left out. |
starting_after | id | The id of the last item on the page before, to get the next page. |
event | id | Only this event's orders. |
status | string | Only orders in this status (see Order below). |
email | Only orders the person with this email bought. | |
created_after | date | Only orders made at or after this time. |
created_before | date | Only orders made at or before this time. |
GET/v1/attendees#
Everyone holding a valid ticket (on a paid or partly refunded order), one row per person, newest order first. Use it for check-in lists and badge printing.
- Needs
registrations:read- Returns
- A list of Attendees.
| Parameter | Type | |
|---|---|---|
limit | 1–100 | How many to return. 25 if left out. |
starting_after | id | The id of the last attendee on the page before. |
event | id | Only this event's attendees. |
order | id | Only the people on this order. |
checked_in | true | false | Only people who have, or haven't, been checked in. |
email | Only the person with this email. |
GET/v1/payouts#
Money Creve has paid out to you, or is paying, newest first.
- Needs
payouts:read- Returns
- A list of Payouts.
| Parameter | Type | |
|---|---|---|
limit | 1–100 | How many to return. 25 if left out. |
starting_after | id | The id of the last item on the page before, to get the next page. |
status | string | One of pending, approved, processing, paid, failed, rejected. |
POST/v1/test_events#
Sends a sample notification of one type to your test webhook endpoints that subscribe to it. Only in the sandbox.
- Needs
A test key- Returns
- How many endpoints it was sent to, and the notification's id.
| Body field | Type | |
|---|---|---|
type | string | A notification type, such as order.paid. |
Objects#
Event#
| Field | Type | |
|---|---|---|
id | string | The event's id. |
object | "event" | |
livemode | boolean | false for sandbox data. |
name | string | |
slug | string | The last part of the event's web address. |
status | string | draft, published, ended, cancelled, or suspended (paused by Creve). |
type | string | conference, webinar, workshop, meetup, networking, seminar, training or other. |
topic | string | null | What the event is about, if the organizer chose one. |
format | string | in_person, virtual or hybrid. |
starts_at, ends_at | time | |
timezone | string | The event's own time zone, such as Africa/Nairobi. |
url | string | null | Its public page, while it's published. |
banner_url | string | null | |
location | object | venue_name, address, city, venue_to_be_announced, and online (true for virtual and hybrid events). The online joining link is never included. |
ticket_types | array | Each ticket type, below. |
published_at, created_at, updated_at | time |
Ticket type#
| Field | Type | |
|---|---|---|
id | string | |
name | string | |
category | string | attendee, exhibitor or sponsor. |
free | boolean | |
price | number | 0 when free. |
currency | string | Such as KES. |
quantity | number | How many there are in total. |
sold | number | How many are held on paid orders. |
available | number | quantity minus sold. |
sales_start_at, sales_end_at | time | |
min_per_order, max_per_order | number | |
hidden | boolean | Offered only with an access code. The code itself is never included. |
Order#
| Field | Type | |
|---|---|---|
id | string | The order's id. It's also the ticket's id. |
object | "order" | |
livemode | boolean | |
status | string | paid; partially_refunded (the ticket still works); refunded, reversed or chargeback (the ticket no longer works); pending_approval (waiting for the organizer to approve); awaiting_payment (approved, not yet paid); rejected; cancelled. |
event | object | id, name, slug, starts_at. |
ticket_type | object | id, name. |
quantity | number | How many tickets. |
currency | string | |
amount_subtotal | number | Before any discount. |
amount_discount | number | Taken off by a promo code. |
amount_total | number | What the buyer paid. |
amount_refunded | number | What has been given back so far. |
amount_fee | number | Creve's fee on this order. |
promo_code | string | null | |
channel | string | online, or door for a Box Office sale. |
payment_method | string | null | online_card, online_mpesa, online_other, door_cash, door_card, door_mpesa, door_card_machine or free. door_card is a card charged through Paystack at the Box Office; door_card_machine is a card taken on the organizer's own terminal and only recorded (before 2026-10-07, door_card meant that). |
buyer | object | first_name, last_name, email, phone. |
attendees | array | Everyone on the order: id, names, email, phone, checked_in_at, and answers to the organizer's questions. |
checked_in_at | time | null | When the first person on the order was let in. |
ticket_url | string | The ticket page. Anyone with this link can open the ticket, so treat it like the ticket itself. |
paid_at, refunded_at, created_at, updated_at | time | null |
Attendee#
| Field | Type | |
|---|---|---|
id | string | The attendee's id. |
object | "attendee" | |
livemode | boolean | |
order, order_status | string | The order they're on, and its status. |
event, ticket_type | object | As on an Order. |
first_name, last_name, email, phone | string | null | Email and phone can be empty for someone sold a ticket at the door. |
checked_in_at | time | null | When they were let in. |
answers | array | question, type and value for each registration question they answered. |
Payout#
| Field | Type | |
|---|---|---|
id | string | |
object | "payout" | |
livemode | boolean | |
amount, currency | number, string | |
status | string | pending, approved, processing, paid, failed or rejected. |
destination | object | type (bank, mobile_money, till or paybill), label, and account showing only the last four digits. |
requested_at | time | |
expected_by | time | null | When it should arrive, if Creve publishes a payout time. Weekends are skipped; public holidays aren't. |
paid_at | time | null | |
failure_reason | string | null | Why a failed payout failed. |
Webhooks#
Add an endpoint in Settings > Developers: an https address on your server, and the notifications you want. Each endpoint gets its own signing secret, shown once.
Notification types#
| type | Sent when | data.object |
|---|---|---|
registration.created | Someone registered (free or paid) | order |
order.paid | A paid order was completed | order |
order.refunded | An order was refunded, in full or in part | order |
checkin.created | An attendee was checked in | attendee |
payout.paid | A payout reached your account | payout |
payout.failed | A payout failed | payout |
- A paid order sends both
registration.createdandorder.paid; a free registration sends onlyregistration.created. order.refundedis sent for every refund, full or partial, and when a payment is reversed or disputed; the order'sstatusandamount_refundedsay which.checkin.createdis sent once for each person let in, so a group arriving together sends one per person.
The message#
Creve sends a POST with a JSON body. data.object is exactly what the API returns for the same record at that moment.
POST /your/webhook/address
Content-Type: application/json
User-Agent: Creve-Webhooks/1
Creve-Signature: t=1790000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Creve-Notification-Id: ntf_3f9a1c0e2b7d4a5f8e6c1d2b
Creve-Notification-Type: checkin.created
{
"id": "ntf_3f9a1c0e2b7d4a5f8e6c1d2b",
"object": "notification",
"type": "checkin.created",
"api_version": "2026-09-26",
"created_at": "2026-10-26T06:12:44.301Z",
"livemode": true,
"data": {
"object": {
"id": "6a1f…",
"object": "attendee",
"first_name": "Wanjiku",
"last_name": "Kamau",
"checked_in_at": "2026-10-26T06:12:43.000Z",
"…": "…"
}
}
}- Answer with any
2xxstatus within 10 seconds. Anything else, including a redirect, counts as a failure. Answer first and do slow work afterwards. - The same message can arrive more than once, and messages can arrive out of order. Use the
idto skip ones you've handled, and the times in the object to put them in order. - Creve doesn't send from fixed addresses: check the signature, not where the request came from.
Checking a message is genuine#
The Creve-Signature header holds a time (t, Unix seconds) and a signature (v1). The signature is an HMAC-SHA256, in hex, of the time, a full stop and the raw body, keyed with your endpoint's secret. Work it out yourself and compare, using a constant-time comparison; refuse messages more than 5 minutes old. Always use the body exactly as it arrived: parsing and re-encoding the JSON changes it.
import crypto from "node:crypto";
import express from "express";
// rawBody is the body exactly as it arrived, before any JSON parsing.
function isFromCreve(rawBody, header, secret) {
const parts = Object.fromEntries(String(header).split(",").map((p) => p.split("=", 2)));
const t = Number(parts.t);
if (!t || !parts.v1) return false;
// Refuse old messages, so a copied one can't be sent again later.
if (Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const app = express();
app.post("/creve/webhooks", express.raw({ type: "application/json" }), (req, res) => {
const raw = req.body.toString("utf8");
if (!isFromCreve(raw, req.get("Creve-Signature"), process.env.CREVE_WEBHOOK_SECRET)) {
return res.status(400).send("Signature didn't match");
}
res.sendStatus(200); // answer first, then do the work
const notification = JSON.parse(raw);
// Skip ids you've already handled: the same message can arrive twice.
});import hashlib, hmac, time
def is_from_creve(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1 or abs(time.time() - int(t)) > 300:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)function is_from_creve(string $rawBody, string $header, string $secret): bool {
parse_str(str_replace(',', '&', $header), $parts);
if (empty($parts['t']) || empty($parts['v1']) || abs(time() - (int) $parts['t']) > 300) {
return false;
}
$expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
return hash_equals($expected, $parts['v1']);
}
// $rawBody = file_get_contents('php://input');
// $header = $_SERVER['HTTP_CREVE_SIGNATURE'] ?? '';Made a new secret in the dashboard? The old one stops working at once, so update your server straight away.
Retries#
If a message isn't answered with a 2xx, Creve tries again after 1 minute, then 5 minutes, then 30 minutes, then 2 hours, then 8 hours, then 24 hours. That's up to 7 attempts over about 35 hours. Retries go out within five minutes of these times.
After the last one, the message is marked failed. You can see every message, each attempt and what your server answered in Settings > Developers, and send any message again from there.
