Developer documentation

Read your events, orders, attendees and payouts from your own software, and have Creve tell it the moment a ticket sells or someone walks in.

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.

A first request
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.

PermissionLets the key read
events:readEvents and ticket types
registrations:readOrders and attendees, with their contact details and answers
payouts:readPayouts

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 id and says what it is in object.
  • 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.

Response
{
  "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.

400 Bad 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-…"
  }
}
StatuscodeMeans
400 invalid_request_errorparameter_invalidA parameter's value isn't allowed. param says which.
parameter_unknownA parameter this request doesn't take, often a misspelling.
body_invalidThe body isn't valid JSON.
version_unsupportedThe Creve-Version header names a version that doesn't exist.
401 authentication_errorapi_key_missingNo Authorization header.
api_key_invalidThe key isn't one Creve made, or was copied wrongly.
api_key_revokedThe key was revoked.
403 permission_errorscope_missingThe key doesn't have the permission this request needs.
api_key_disabledCreve has switched the key off.
test_mode_onlyThe request is only for test keys.
404 invalid_request_errorresource_missingThere's no such event, order or payout on your account.
route_not_foundThe API has no such request.
413 invalid_request_errorbody_too_largeThe body is over 5 MB.
429 rate_limit_errorrate_limitedToo many requests this minute. Wait the seconds in Retry-After.
too_many_failed_keysToo many requests with a missing or wrong key from your address. Wait 15 minutes.
500 api_errorinternal_errorSomething went wrong at Creve. Try again; if it keeps happening, tell us the request_id.
503 api_errorapi_disabledCreve 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 RateLimit header, such as limit=120, remaining=87, reset=41: requests left, and seconds until the count starts again.
  • Over the limit, you get 429 with a Retry-After header 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
GET/v1/account
GET/v1/events
GET/v1/events/{id}
GET/v1/orders
GET/v1/orders/{id}
GET/v1/attendees
GET/v1/payouts
GET/v1/payouts/{id}
POST/v1/test_events

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.
ParameterType
limit1–100How many to return. 25 if left out.
starting_afteridThe id of the last item on the page before, to get the next page.
statusstringOne of draft, published, ended, cancelled, suspended.
starts_afterdateOnly events starting at or after this time.
starts_beforedateOnly 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.
ParameterType
limit1–100How many to return. 25 if left out.
starting_afteridThe id of the last item on the page before, to get the next page.
eventidOnly this event's orders.
statusstringOnly orders in this status (see Order below).
emailemailOnly orders the person with this email bought.
created_afterdateOnly orders made at or after this time.
created_beforedateOnly orders made at or before this time.

GET/v1/orders/{id}#

One order.

Needs
registrations:read
Returns
An Order.

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.
ParameterType
limit1–100How many to return. 25 if left out.
starting_afteridThe id of the last attendee on the page before.
eventidOnly this event's attendees.
orderidOnly the people on this order.
checked_intrue | falseOnly people who have, or haven't, been checked in.
emailemailOnly 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.
ParameterType
limit1–100How many to return. 25 if left out.
starting_afteridThe id of the last item on the page before, to get the next page.
statusstringOne of pending, approved, processing, paid, failed, rejected.

GET/v1/payouts/{id}#

One payout.

Needs
payouts:read
Returns
A Payout.

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 fieldType
typestringA notification type, such as order.paid.

Objects#

Event#

FieldType
idstringThe event's id.
object"event"
livemodebooleanfalse for sandbox data.
namestring
slugstringThe last part of the event's web address.
statusstringdraft, published, ended, cancelled, or suspended (paused by Creve).
typestringconference, webinar, workshop, meetup, networking, seminar, training or other.
topicstring | nullWhat the event is about, if the organizer chose one.
formatstringin_person, virtual or hybrid.
starts_at, ends_attime
timezonestringThe event's own time zone, such as Africa/Nairobi.
urlstring | nullIts public page, while it's published.
banner_urlstring | null
locationobjectvenue_name, address, city, venue_to_be_announced, and online (true for virtual and hybrid events). The online joining link is never included.
ticket_typesarrayEach ticket type, below.
published_at, created_at, updated_attime

Ticket type#

FieldType
idstring
namestring
categorystringattendee, exhibitor or sponsor.
freeboolean
pricenumber0 when free.
currencystringSuch as KES.
quantitynumberHow many there are in total.
soldnumberHow many are held on paid orders.
availablenumberquantity minus sold.
sales_start_at, sales_end_attime
min_per_order, max_per_ordernumber
hiddenbooleanOffered only with an access code. The code itself is never included.

Order#

FieldType
idstringThe order's id. It's also the ticket's id.
object"order"
livemodeboolean
statusstringpaid; 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.
eventobjectid, name, slug, starts_at.
ticket_typeobjectid, name.
quantitynumberHow many tickets.
currencystring
amount_subtotalnumberBefore any discount.
amount_discountnumberTaken off by a promo code.
amount_totalnumberWhat the buyer paid.
amount_refundednumberWhat has been given back so far.
amount_feenumberCreve's fee on this order.
promo_codestring | null
channelstringonline, or door for a Box Office sale.
payment_methodstring | nullonline_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).
buyerobjectfirst_name, last_name, email, phone.
attendeesarrayEveryone on the order: id, names, email, phone, checked_in_at, and answers to the organizer's questions.
checked_in_attime | nullWhen the first person on the order was let in.
ticket_urlstringThe ticket page. Anyone with this link can open the ticket, so treat it like the ticket itself.
paid_at, refunded_at, created_at, updated_attime | null

Attendee#

FieldType
idstringThe attendee's id.
object"attendee"
livemodeboolean
order, order_statusstringThe order they're on, and its status.
event, ticket_typeobjectAs on an Order.
first_name, last_name, email, phonestring | nullEmail and phone can be empty for someone sold a ticket at the door.
checked_in_attime | nullWhen they were let in.
answersarrayquestion, type and value for each registration question they answered.

Payout#

FieldType
idstring
object"payout"
livemodeboolean
amount, currencynumber, string
statusstringpending, approved, processing, paid, failed or rejected.
destinationobjecttype (bank, mobile_money, till or paybill), label, and account showing only the last four digits.
requested_attime
expected_bytime | nullWhen it should arrive, if Creve publishes a payout time. Weekends are skipped; public holidays aren't.
paid_attime | null
failure_reasonstring | nullWhy 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#

typeSent whendata.object
registration.createdSomeone registered (free or paid)order
order.paidA paid order was completedorder
order.refundedAn order was refunded, in full or in partorder
checkin.createdAn attendee was checked inattendee
payout.paidA payout reached your accountpayout
payout.failedA payout failedpayout
  • A paid order sends both registration.created and order.paid; a free registration sends only registration.created.
  • order.refundedis sent for every refund, full or partial, and when a payment is reversed or disputed; the order's status and amount_refunded say which.
  • checkin.created is 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 2xx status 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.

Node.js (Express)
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.
});
Python
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)
PHP
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.