CEEJAYDocs

Webhooks

Receive a signed HTTP POST the moment something happens in your community, instead of polling for it.

Open Settings → Developers → Webhooks and press Add Webhook. Give it a name (up to 100 characters), the URL we should call (up to 500) and the events you want, then press Create. CEEJAY generates a signing secret for the webhook and uses it to sign every delivery.

Copy the signing secret when you create the webhook

CEEJAY shows the signing secret once, when you create the webhook. Copy it then: it can't be retrieved later. The secret is 64 hexadecimal characters.

The secret stays on screen until you press Add Webhook again or reload the page. CEEJAY does not show the secret again anywhere, and the list of webhooks does not include it. If you lose it, delete the webhook and add it again: the new webhook gets a new secret. You need it to verify the signature.

You can turn a webhook on or off, send a test event, view its recent deliveries (event, HTTP status, response time and time) or delete it.

Events

EventFires when
member.joinedSomeone joins your Discord server. Not sent for bot accounts
member.leftSomeone leaves or is removed. Not sent for bot accounts
quest.completedA member completes a quest: in Discord, through Complete a quest, or when Complete a quest task finishes the quest
ticket.createdA support ticket is opened
custom.*You emit one via POST /v1/events/emit. When you add the webhook, tick custom.event, which the dialog describes as An event sent through the API
*Every event on this page. Tick *, which the dialog describes as Every event

The test button sends a test.ping event, which you do not choose when you add a webhook. Its data contains message, subscriptionId (the webhook's ID) and subscriptionName. The test button shows no message of its own: view the recent deliveries to see the test and how your server responded.

Member joined, member left, quest completed, ticket created

These four have their own data:

Eventdata fields
member.joineduserId, discordId, username, joinedAt, inviteType (how they joined, when CEEJAY can tell, otherwise null), memberCount (members in the server now)
member.leftuserId, discordId, username, leftAt, memberCount
quest.completeduserId, discordId, questId, questTitle, questType, rewards (xp, currency, badge as a name or null, roleGranted as true or false), completedAt
ticket.createdticketId, ticketNumber, panelName, userId, discordId, username, channelId, createdAt

discordId has the same value as userId. It is the name the rest of the API uses for the member, and userId stays for as long as version 1 exists.

For a quest completed through Complete a quest, quest.completed is sent once, shortly after the response. roleGranted is false when the quest's role had not been assigned yet at that moment: no second event follows when it is.

Member actions

One event each time a member does something in one of your apps. CEEJAY sends one event per action, however many times it logs that action.

EventFires when a member
poll.votedVotes in a poll (once per poll)
form.submittedSubmits a form (once per form)
suggestion.postedPosts a suggestion
suggestion.upvotedUpvotes a suggestion
event.rsvpRSVPs to an event
event.attendedAttends an event
member.level_upReaches a new level
badge.earnedEarns a badge
shop.purchasedBuys from the shop
member.checked_inDoes their daily check-in
giveaway.enteredEnters a giveaway
giveaway.wonWins a giveaway
reward.receivedReceives a reward
content.postedPosts in a content channel
ticket.closedHas their ticket closed
program.joinedJoins a program
account.linkedLinks an account on another platform
email.openedOpens an email for the first time
role.pickedPicks a role
member.verifiedPasses verification
member.invitedInvites someone who joins
player.connectedConnects a game player account: see Player connected and player disconnected
player.disconnectedDisconnects a game player account

They all carry the same data:

Member action data
{
  "userId": "409876543210987654",
  "app": "polls",
  "action": "voted",
  "targetType": "poll",
  "targetId": "41",
  "targetName": null,
  "itemId": "41",
  "quantity": null,
  "occurredAt": "2026-10-01T09:14:22.118Z",
  "discordId": "409876543210987654"
}
FieldMeaning
userId, discordIdThe member's Discord ID, under both names
app, actionWhich app, and what they did in it
targetType, targetIdWhat it was done to: the poll, the giveaway, the shop item. For account.linked, targetId is the platform
targetNameIts name. CEEJAY does not fill this in yet, so it is always null
itemIdThe item in that app that a campaign or category is assigned to. For a ticket this is its panel
quantityA numeric value tied to the action: the price paid, the level reached, the streak, the amount of a reward
detailsPresent only when the event carries extra data. For reward.received: what the reward was for (sourceKind, sourceId) and what it was (rewardId, rewardType). For role.picked when several roles were picked at once: all of them in roleIds, the first also being targetId. For player.connected and player.disconnected: see below

Player connected and player disconnected

Two events tell your server about the players of a game set up for Connect. They carry the member-action data above, with app connect, targetType game and the game's slug in targetId, and a details object that identifies the player:

player.connected data
{
  "userId": "409876543210987654",
  "app": "connect",
  "action": "game_connected",
  "targetType": "game",
  "targetId": "rugby_manager",
  "targetName": null,
  "itemId": null,
  "quantity": null,
  "occurredAt": "2026-10-08T18:06:41.002Z",
  "details": {
    "playerId": "p_19",
    "proof": "verified",
    "origin": "connect",
    "connectedAt": "2026-10-08T18:06:40.000Z"
  },
  "discordId": "409876543210987654"
}
details fieldMeaning
playerIdThe game's own ID for the player
proofverified when the player was verified, basic when the game supplied the ID without verification. Check it before you trust the ID: anyone with the game's public key can supply any ID
originconnect when the member made the connection through Connect, api when your own server linked the player with Link an account
connectedAtWhen the connection was made
previousPlayerIdplayer.connected only, and only when the member moved from another player of the game and both connections were verified
reasonplayer.disconnected only: why it ended, such as member, team, studio, banned, moved, superseded, left, under_age, erased or game_removed. Treat the list as open
  • player.connected is sent for a new connection, a connection made again, and a connection moved to another player. It is the main way for your server to receive the result of a connect session. Get a connect session and List connect sessions are the fallback.
  • player.disconnected is sent whoever ends the connection: the member, the community's team, your server (Disconnect a player and Delete a player), a ban, a member who has not been in the server for 30 days, an erasure of the member's data, or the game being removed.
  • They need the member history to be enabled for your community. Where it is switched off, Connect still works, and your server must poll for results.
  • occurredAt is the time CEEJAY's bot processed the connection, shortly after it was made. The time of the connection is details.connectedAt.
  • One event for each connection and each change. A player connected, moved and connected again sends each time. The logged game events of Log player events send no webhook: a subscription to every event would receive back every event your server sent.

Delivery

Every delivery is a POST with a JSON body in the same envelope.

Delivery request
POST /your/webhook HTTP/1.1
Content-Type: application/json
X-CEEJAY-Event: quest.completed
X-CEEJAY-Delivery: evt_3f9c1d7a5b2e48c6a0d1f7e9b4c8a2d6
X-CEEJAY-Attempt: 1
X-CEEJAY-Timestamp: 1791364462
X-CEEJAY-Signature-V2: 1a3c64a6265c1984c3e1efbb3aca6fe3...
X-CEEJAY-Signature: sha256=9f2b1c...

{
  "event": "quest.completed",
  "data": {
    "userId": "409876543210987654",
    "questId": 42,
    "questTitle": "Reach level 10",
    "questType": "reach_level",
    "rewards": { "xp": 500, "currency": 250, "badge": null, "roleGranted": false },
    "completedAt": "2026-10-03T09:14:22.118Z",
    "discordId": "409876543210987654"
  },
  "serverId": "721104938271003842",
  "timestamp": "2026-10-03T09:14:22.431Z",
  "id": "evt_3f9c1d7a5b2e48c6a0d1f7e9b4c8a2d6"
}
FieldMeaning
eventThe event name, the same as the X-CEEJAY-Event header
dataThe event's own fields, described above
serverIdYour Discord server ID
timestampWhen CEEJAY created the event. A retry keeps it
idUnique for this event. It starts with evt_. A retry carries the same id
HeaderMeaning
X-CEEJAY-EventThe event name
X-CEEJAY-DeliveryThe delivery ID: the same value as id in the body, and the same on every retry of this delivery. Letters, digits, _ and -, up to 80
X-CEEJAY-Attempt1 for the first attempt, then 2, 3 and so on. Digits, with no zeros in front
X-CEEJAY-TimestampThe Unix time, in seconds, at which this attempt was signed. Digits, with no zeros in front. A retry carries a new one
X-CEEJAY-Signature-V2The signature of this attempt: 64 lower-case hexadecimal characters. See Verifying the signature
X-CEEJAY-SignatureThe legacy signature, still sent on every delivery. See The legacy signature

Respond with any 2xx within 5 seconds, then process the event asynchronously.

Where it can be sent

The URL must be a public https:// URL:

  • It cannot contain a username or password.
  • A host name must resolve, and every IP address it resolves to must be public. localhost, names ending in .local, .internal or .localhost, names without a dot, and IP addresses on a private, loopback or link-local network are refused.
  • It is checked when you save the webhook, where you are told why it was refused, and again before every delivery. A URL that is refused at delivery time sends nothing and counts as a failed delivery.
  • Redirects are not followed: a 3xx response counts as a failed delivery, so give the final URL.

Failed deliveries and retries

A delivery fails when your server responds with anything other than 2xx (a 3xx redirect included), does not respond within 5 seconds, cannot be reached, or the URL is refused.

CEEJAY then retries up to four more times: about 30 seconds, 2 minutes, 4½ minutes and 8 minutes after the first attempt. The retries run once a minute, so each can be delivered up to a minute late. They stop after the fifth attempt, after 24 hours, or when you switch the webhook off.

A retry sends the stored copy of the event again, with the same id, timestamp and data, and signs it again: X-CEEJAY-Delivery stays the same, X-CEEJAY-Attempt goes up by one and X-CEEJAY-Timestamp is the time of the retry. So deliveries are at least once: use X-CEEJAY-Delivery (or id) to ignore an event you have already handled. Events can also arrive out of order, because a retry is delivered after newer events. Order by occurredAt or timestamp, and do not reject a delivery for being old.

The stored copy is re-written as JSON, so the order of the keys can differ from the first attempt. The signature is computed over the bytes of the attempt it arrives with, so it always matches the body you receive: check each delivery against its own body, never against one you saved from an earlier attempt.

Automatic disabling

Each new event whose first delivery fails adds one to the webhook's failure counter, which the dashboard shows as a red badge. Retries do not change it. After 10 failures in a row the webhook is switched off and stops firing. A successful first delivery resets the counter to zero. CEEJAY sends the community's team a notification in the dashboard when it switches a webhook off.

Switching a disabled webhook back on resets its counter to zero and clears its last error, so it gets ten more failures in a row before it is switched off again. A test does not change the counter.

Each webhook's delivery history shows the event, the HTTP status, how long your server took and when. CEEJAY retains each webhook's delivery history for 30 days, then deletes it.

Verifying the signature

Every delivery is signed with your webhook's signing secret: the one the dashboard showed once, when you created the webhook. Use the secret as the text you were shown, all 64 characters, and do not decode it from hex.

X-CEEJAY-Signature-V2 is the HMAC-SHA256, written as 64 lower-case hexadecimal characters, of this text:

What is signed
v2.<X-CEEJAY-Timestamp>.<X-CEEJAY-Delivery>.<X-CEEJAY-Attempt>.<raw request body>

The time, the delivery ID and the attempt number are inside what is signed, so a replayed request cannot be given a new time or a new number. To check a delivery:

  1. Check that the four headers are there and have their forms: 64 lower-case hexadecimal characters, an ID of letters, digits, _ and -, and two numbers written in digits with no zeros in front.
  2. Refuse a delivery whose X-CEEJAY-Timestamp is more than 300 seconds from your own clock, either way. Write the test as "refuse unless it is inside the tolerance", so a clock or a setting that is not a number refuses every delivery.
  3. Work out the signature over the body exactly as it arrived, and compare in constant time. Parsing the body and writing it out again changes the bytes and breaks the signature.
  4. Keep each X-CEEJAY-Delivery with its X-CEEJAY-Attempt for five minutes, and ignore one you have already handled. Inside the 300 seconds a captured delivery can be replayed: nothing in it can be altered, but the same request can be sent again.
Verify the signature (Node.js)
import crypto from 'node:crypto'

const TOLERANCE_SECONDS = 300

// headers: the request headers, with lower-case names (as Node and Express give them)
export function verify(rawBody, headers, secret) {
  const signature = headers['x-ceejay-signature-v2'] ?? ''
  const deliveryId = headers['x-ceejay-delivery'] ?? ''
  const attempt = headers['x-ceejay-attempt'] ?? ''
  const timestamp = headers['x-ceejay-timestamp'] ?? ''

  if (
    !/^[0-9a-f]{64}$/.test(signature) || !/^[A-Za-z0-9_-]{1,80}$/.test(deliveryId) ||
    !/^[1-9][0-9]{0,8}$/.test(attempt) || !/^[1-9][0-9]{0,11}$/.test(timestamp)
  ) return false

  // Refuse unless the time is inside the tolerance
  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp))
  if (!(age <= TOLERANCE_SECONDS)) return false

  const expected = crypto.createHmac('sha256', secret)
    .update(`v2.${timestamp}.${deliveryId}.${attempt}.`)
    .update(rawBody)
    .digest('hex')

  return crypto.timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex'))
}

In Express, take the raw body with express.raw, respond first, then process. app is your Express app and verify is the function above:

Verify in Express (Node.js)
app.post('/ceejay/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verify(req.body, req.headers, process.env.CEEJAY_WEBHOOK_SECRET)) {
    return res.status(401).end()
  }

  const event = JSON.parse(req.body.toString('utf8'))
  res.status(200).end()

  // Ignore a delivery ID and attempt you have already handled, then process the event.
})

To try your own code, sign this by hand: the secret a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90, the timestamp 1791364462, the delivery ID evt_0a1b2c3d4e5f60718293a4b5c6d7e8f9, attempt 1 and the body below give the signature 1a3c64a6265c1984c3e1efbb3aca6fe338803b2d8f38f29f187103f030233529.

The body of the test
{"event":"member.joined","data":{"userId":"409876543210987654","discordId":"409876543210987654"},"serverId":"721104938271003842","timestamp":"2026-10-07T09:14:22.431Z","id":"evt_0a1b2c3d4e5f60718293a4b5c6d7e8f9"}

The legacy signature

Before version 1.0.0 a delivery carried one signature header, and it is still sent on every delivery, unchanged, for as long as version 1 exists. X-CEEJAY-Signature is sha256= followed by an HMAC-SHA256 of the raw request body, keyed with the same secret. It includes no time, no delivery ID and no attempt number, so a replayed request can be sent again at any time. Code that checks it keeps working. Check X-CEEJAY-Signature-V2 in new code, and update existing code when you can: nothing has to be set in CEEJAY to switch.

Verify the legacy signature (Node.js)
import crypto from 'node:crypto'

export function verifyOlder(rawBody, header, secret) {
  const expected = Buffer.from(
    'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex'),
  )
  const given = Buffer.from(header ?? '')

  return given.length === expected.length && crypto.timingSafeEqual(given, expected)
}

Emitting your own events

POST /v1/events/emit with a body of { "event": "purchase.completed", "data": { … } } fans the payload out to every webhook listening for it.

Names are automatically namespaced under custom., so purchase.completed is delivered as custom.purchase.completed. A name that already starts with custom. is left as it is. A name is 1 to 93 letters, digits, ., _, : or -: anything else is rejected and not delivered. A webhook receives it when it listens for custom.event (An event sent through the API), for that exact name, or for * (Every event). Your data is delivered as you sent it: CEEJAY adds nothing to it. One call makes one delivery ID, so every webhook receives the same id and X-CEEJAY-Delivery. See Emit an event.

On this page

Community StudiosPrivacyTerms