CEEJAYDocs

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 subscriptionShop delivery
What it isAn event, sent to a webhook you added in the dashboardA purchase of a shop item that is Delivered by your API
FunctionverifyWebhookverifyDelivery
SecretThe signing secret the dashboard showed once, when you created the webhookThe secret you set on the shop item

The signature

Every delivery carries four headers. Together they are signature version 2.

HeaderWhat it is
X-CEEJAY-DeliveryThe 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-AttemptWhich attempt of the delivery this is: 1 for the first, then 2, 3 and so on
X-CEEJAY-TimestampThe Unix time, in seconds, at which this attempt was signed
X-CEEJAY-Signature-V2The HMAC-SHA256, as 64 lower-case hex characters, of the text below, keyed with your secret
The signature
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

  1. 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.
  2. Respond with 401 when the check fails. Both functions throw a CeejaySignatureError then. They return the payload when the signature is right, so there is no true or false to forget to test.
  3. Use the secret as the text it is. Do not decode it from hex.
  4. Store each delivery.id with its delivery.attempt for 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.

verifyWebhook (Request and Response)
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.id and the next delivery.attempt, and is signed again at its own time. Store the id of 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

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.

verifyDelivery, the player
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 withCEEJAY
200 with { "status": "delivered" }, or 204Marks 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 429Refunds the member
408, 429, a 5xx, or no response within 5 secondsRetries 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:

verifyDelivery, respond with pending
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:

verifyDelivery, another 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.

reasonWhat happened
no_headerThe request has no X-CEEJAY-Signature-V2 header
malformedThe signature is not 64 lower-case hex characters, or one of the three accompanying headers is missing or not in its form
expiredThe time the attempt was signed at is further from now than the tolerance allows
mismatchThe signature is not the one this secret gives for this body, time, delivery ID and attempt
not_jsonThe 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.

When a check fails
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 subscriptionShop delivery
X-CEEJAY-Signaturesha256= and a hex HMAC-SHA256 of the raw bodyt= and a time in seconds, a comma, then v1= and a hex HMAC-SHA256 of the time, a dot and the raw body
Known weaknessIt has no timestamp, so a replayed request is always acceptedIt 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:

Legacy signatures
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-V2 is 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 reason is no_header when that header is missing too. The payload then has no delivery.

Remove the option once your CEEJAY instance sends the four headers.

On this page

Community StudiosPrivacyTerms