Verifying signatures
Check that a request your server receives really comes from CEEJAY, before you act on it.
CEEJAY sends your server two kinds of request, and signs both the same way. The SDK has one function for each. They use Web Crypto only, so they run on Node, Deno and edge functions alike.
| Webhook subscription | Shop delivery | |
|---|---|---|
| What it is | An event, sent to a webhook you added in the dashboard | A purchase of a shop item that is Delivered by your API |
| Function | verifyWebhook | verifyDelivery |
| Secret | The signing secret the dashboard showed once, when you created the webhook | The secret you set on the shop item |
The signature
Every delivery carries four headers. Together they are signature version 2.
| Header | What it is |
|---|---|
X-CEEJAY-Delivery | The delivery ID. For a webhook it is the id of the event. For a shop delivery it is the delivery_id of the body. A retry of the same delivery has the same ID |
X-CEEJAY-Attempt | Which attempt of the delivery this is: 1 for the first, then 2, 3 and so on |
X-CEEJAY-Timestamp | The Unix time, in seconds, at which this attempt was signed |
X-CEEJAY-Signature-V2 | The HMAC-SHA256, as 64 lower-case hex characters, of the text below, keyed with your secret |
v2.<X-CEEJAY-Timestamp>.<X-CEEJAY-Delivery>.<X-CEEJAY-Attempt>.<raw body>The time, the delivery ID and the attempt number are part of what is signed, so a replayed request cannot be given a new time or a new number.
Both functions check all of it: that the four headers are there and in their form, that
the time is no more than 5 minutes from now, either way, and that the signature is the one
your secret gives. They return the payload with delivery added: the ID, the attempt and
the time, read from the headers.
Four rules for both
- Pass the body exactly as it arrived, as text or as bytes, before you parse it. A body that was parsed and written out again has other bytes, and its signature no longer matches.
- Respond with
401when the check fails. Both functions throw aCeejaySignatureErrorthen. They return the payload when the signature is right, so there is notrueorfalseto forget to test. - Use the secret as the text it is. Do not decode it from hex.
- Store each
delivery.idwith itsdelivery.attemptfor five minutes, and ignore one you have already handled. Within the 5 minutes a replayed request still passes the check: nothing in it can be changed, but the same request can be sent again.
verifyWebhook
The first handler uses the web standard Request and Response, which Deno, edge functions and many Node frameworks share. The second is for Node without a framework: it collects the bytes of the request itself.
import { CeejaySignatureError, verifyWebhook } from '@communitystudios/ceejay'
const seen = new Set<string>() // use your database or a cache in production, and drop an entry after five minutes
export async function handleWebhook(request: Request): Promise<Response> {
const rawBody = await request.text()
let event
try {
event = await verifyWebhook(rawBody, request.headers, process.env.CEEJAY_WEBHOOK_SECRET)
} catch (error) {
if (error instanceof CeejaySignatureError) return new Response(null, { status: 401 })
throw error
}
// The same delivery and attempt a second time is a copy: answer it, and do nothing.
const attempt = `${event.delivery.id}:${event.delivery.attempt}`
if (seen.has(attempt)) return new Response(null, { status: 200 })
seen.add(attempt)
if (event.event === 'quest.completed') {
console.log(event.data.discordId, event.data.questTitle, event.data.rewards.xp)
} else if (event.event === 'member.joined') {
console.log(event.data.username, event.data.memberCount)
}
return new Response(null, { status: 200 })
}Parameters
Prop
Type
The options are typed as VerifyWebhookOptions.
Returns the event, typed as WebhookEvent, with delivery added.
Prop
Type
An event about a member identifies the member as data.discordId. data.userId contains
the same ID. It is the original field name, and it is still sent.
An event you sent yourself with ceejay.events.emit has a name that starts with custom.,
and whatever data you sent.
What to do in your handler
- Respond with any status from 200 to 299 within 5 seconds. Acknowledge first, and do your real work afterwards.
- CEEJAY retries a failed delivery up to four more times, so the same event can
arrive more than once. A retry has the same
delivery.idand the nextdelivery.attempt, and is signed again at its own time. Store theidof each event you have acted on, and do not act on one twice. - Events can arrive out of order, because a retry may arrive after newer events.
In Express, read the raw body with express.raw({ type: 'application/json' }), then pass
req.body and req.headers. With express.json() the body is already parsed, and the
check cannot work.
verifyDelivery
import { CeejaySignatureError, verifyDelivery } from '@communitystudios/ceejay'
const handled = new Set<string>() // use your database in production
export async function handleDelivery(request: Request): Promise<Response> {
const rawBody = await request.text()
let delivery
try {
delivery = await verifyDelivery(rawBody, request.headers, process.env.CEEJAY_SHOP_SECRET)
} catch (error) {
if (error instanceof CeejaySignatureError) return new Response(null, { status: 401 })
throw error
}
// A test from the dashboard: answer it, and unlock nothing.
if (delivery.event === 'shop.test') return new Response(null, { status: 204 })
// CEEJAY sends the same delivery again when it got no final answer: unlock once.
if (!handled.has(delivery.delivery_id)) {
handled.add(delivery.delivery_id)
console.log(`Unlock ${delivery.item.item_key} for ${delivery.member.discord_id}, attempt ${delivery.delivery.attempt}`)
}
return Response.json({ status: 'delivered' })
}Parameters
Prop
Type
The options are typed as VerifyDeliveryOptions.
Returns the delivery, typed as ShopDelivery, with delivery added.
Prop
Type
The body of a shop delivery uses the field names it has always had, in lower case with underscores.
player identifies the player of your game, so the item can be delivered to the right player: a purchase
made in a game's shop is for a player your server already knows from Connect. It is read from
the purchase, never from the connection as it is now, so a player who moves to another ID
between the purchase and a retry does not redirect the item. A studio that only trusts proven
players checks proof and responds with failed for a basic player: the coins are refunded.
import { verifyDelivery } from '@communitystudios/ceejay'
export async function playerToGive(rawBody: string, headers: Headers): Promise<string | null> {
const delivery = await verifyDelivery(rawBody, headers, process.env.CEEJAY_SHOP_SECRET)
// Bought in Discord, or a test: there is no player.
if (delivery.player === undefined) return null
// Only a proven player gets the item.
if (delivery.player.proof !== 'verified') return null
return `${delivery.player.game}:${delivery.player.player_id}`
}What your response means
| You respond with | CEEJAY |
|---|---|
200 with { "status": "delivered" }, or 204 | Marks the purchase delivered |
202, or { "status": "pending" } | Waits for you to call ceejay.shop.completePurchase |
{ "status": "failed" }, a redirect, or a status from 400 to 499 other than 408 and 429 | Refunds the member |
408, 429, a 5xx, or no response within 5 seconds | Retries later, then refunds |
A 401 counts as a rejection, so a wrong secret refunds the member. After you change the
secret of an item, click Send a test delivery in the dashboard before real purchases
come in.
When unlocking takes longer than a few seconds, respond with pending and complete the purchase later.
ceejay.shop.completePurchase accepts the purchase as a number. purchase_id is a number
for a purchase and for a reward, and text for a test, so its type is "number or text".
Return early for a test, then check that it is a number. After that check, TypeScript knows
it is one, and the call compiles:
import { CeejaySignatureError, verifyDelivery } from '@communitystudios/ceejay'
const waiting: number[] = [] // use your own queue or database in production
export async function handleSlowDelivery(request: Request): Promise<Response> {
const rawBody = await request.text()
let delivery
try {
delivery = await verifyDelivery(rawBody, request.headers, process.env.CEEJAY_SHOP_SECRET)
} catch (error) {
if (error instanceof CeejaySignatureError) return new Response(null, { status: 401 })
throw error
}
// A test from the dashboard. Its purchase_id is text, and it is never completed.
if (delivery.event === 'shop.test') return new Response(null, { status: 204 })
// A purchase and a reward carry a number. This check tells the types so.
if (typeof delivery.purchase_id === 'number') waiting.push(delivery.purchase_id)
return Response.json({ status: 'pending' })
}
// Later, when the unlocking has ended one way or the other.
export async function finish(purchaseId: number, unlocked: boolean) {
const result = await ceejay.shop.completePurchase(
purchaseId,
unlocked ? { status: 'delivered' } : { status: 'failed', message: 'The player has no account on this realm' },
{ idempotencyKey: `purchase-${purchaseId}:${unlocked ? 'delivered' : 'failed'}` },
)
console.log(result.data.status)
}Without the check, completePurchase(delivery.purchase_id, …) does not compile, because
text is not a purchase number.
A server that lost a delivery it responded to with pending can find it again with
ceejay.shop.listPurchases. See Shop.
To allow for a larger clock difference, pass a different tolerance:
import { verifyDelivery } from '@communitystudios/ceejay'
export async function check(rawBody: string, headers: Headers) {
return verifyDelivery(rawBody, headers, process.env.CEEJAY_SHOP_SECRET, { toleranceSeconds: 600 })
}When a check fails
Both functions throw a CeejaySignatureError. Its reason explains why.
reason | What happened |
|---|---|
no_header | The request has no X-CEEJAY-Signature-V2 header |
malformed | The signature is not 64 lower-case hex characters, or one of the three accompanying headers is missing or not in its form |
expired | The time the attempt was signed at is further from now than the tolerance allows |
mismatch | The signature is not the one this secret gives for this body, time, delivery ID and attempt |
not_json | The signature is right, but the body is not a JSON object |
Respond to all five with 401. For mismatch, the two usual causes are a wrong secret and a
body that was parsed before the check. For expired, check the clock of your server.
import { CeejaySignatureError, verifyWebhook } from '@communitystudios/ceejay'
export async function statusFor(rawBody: string, headers: Headers): Promise<number> {
try {
await verifyWebhook(rawBody, headers, process.env.CEEJAY_WEBHOOK_SECRET)
return 200
} catch (error) {
if (!(error instanceof CeejaySignatureError)) throw error
console.warn(`Refused a request that says it comes from CEEJAY: ${error.reason}`)
return 401
}
}A missing secret, a body that is an object instead of text or bytes, and an invalid option
are mistakes in your own code. They throw a TypeError, not
a CeejaySignatureError.
Legacy signatures
In addition to the four headers, every delivery still includes the legacy signature CEEJAY
has always sent, in the X-CEEJAY-Signature header. It is sent for as long as version 1 of
the API exists, for servers that verify it with their own code.
| Webhook subscription | Shop delivery | |
|---|---|---|
X-CEEJAY-Signature | sha256= and a hex HMAC-SHA256 of the raw body | t= and a time in seconds, a comma, then v1= and a hex HMAC-SHA256 of the time, a dot and the raw body |
| Known weakness | It has no timestamp, so a replayed request is always accepted | It does not cover the delivery ID or the attempt number |
The two functions do not accept it by default. Every CEEJAY instance this SDK supports sends signature version 2, and a function that also accepted the legacy signature would accept a replayed request with the new headers removed.
If your CEEJAY instance does not send the four headers yet, pass allowLegacy:
import { verifyWebhook } from '@communitystudios/ceejay'
export async function checkEither(rawBody: string, headers: Headers) {
const event = await verifyWebhook(rawBody, headers, process.env.CEEJAY_WEBHOOK_SECRET, { allowLegacy: true })
// `delivery` is there when the request carried signature version 2, and missing when it carried the older one only.
console.log(event.id, event.delivery?.attempt)
return event
}With allowLegacy:
- A request that includes
X-CEEJAY-Signature-V2is verified by it alone. A wrong version 2 signature is rejected, whatever the legacy header says. - A request without it is verified by the legacy signature of that function. Its
reasonisno_headerwhen that header is missing too. The payload then has nodelivery.
Remove the option once your CEEJAY instance sends the four headers.

