SDKwebhooks

junjo.webhooks

Two responsibilities live under this namespace:

  1. endpoints: CRUD for the webhook endpoints Junjo POSTs to.
  2. verify / verifyWithMeta / middleware: receiver-side helpers that validate the HMAC signature on inbound deliveries and parse them into typed JunjoEvent values. Pair with the HTTP wire format docs for the corresponding server side.

The same helpers are also exported standalone (verifyWebhook, verifyWebhookWithMeta, signWebhookBody) for receivers that do not want to construct a client.

webhooks.verify and webhooks.middleware are server-only helpers (they run on the dev’s backend that receives webhooks). The signing math is HMAC-SHA256 via the Web Crypto API, so the same code runs on Node 20+ (the package’s engines floor) and modern browsers without node:crypto or @types/node.

import express from "express";
import { Junjo } from "@junjo.io/sdk";
 
const app = express();
const junjo = new Junjo({ apiKey: process.env.JUNJO_API_KEY! });
 
app.post(
  "/webhooks/junjo",
  express.raw({ type: "application/json" }),
  junjo.webhooks.middleware(process.env.JUNJO_WEBHOOK_SECRET!),
  (req, res) => {
    const event = req.body; // typed as JunjoEvent
    if (event.type === "member.joined") {
      // ...
    }
    res.sendStatus(204);
  },
);

verify(rawBody, headers, secret, opts?)

Returns Promise<JunjoEvent>. Throws JunjoError on any verification failure.

verify(
  rawBody: string | Uint8Array,
  headers: WebhookHeaders,
  secret: string,
  opts?: VerifyOptions,
): Promise<JunjoEvent>;
 
interface VerifyOptions {
  tolerance?: number; // ms; defaults to 5 * 60_000 (5 minutes)
  now?: () => Date; // override the wall clock for tests
}
 
type WebhookHeaders =
  | WebhookSignatureHeaders
  | Record<string, string | string[] | undefined>;

Behavior

  1. Reads x-junjo-signature and x-junjo-timestamp from the supplied headers (case-insensitive; arrays are accepted and the first element wins).
  2. Validates the timestamp is a parseable ISO 8601 string.
  3. Validates the timestamp is within tolerance of now. The default is 5 minutes; raise it only if your clocks are known to drift.
  4. Computes HMAC-SHA256 of <timestamp>.<body> with secret and constant-time compares against the x-junjo-signature header (which is v1=<hex>).
  5. JSON.parses the body and rehydrates Date fields and branded ids via deserializeEvent.

Errors

Every failure mode throws JunjoError with status: 400. Branch on error.code:

CodeWhen
webhook_signature_missingx-junjo-signature header is absent.
webhook_timestamp_missingx-junjo-timestamp header is absent.
webhook_timestamp_invalidThe timestamp is not parseable as ISO 8601.
webhook_timestamp_out_of_toleranceSkew exceeds tolerance (default 5 minutes).
webhook_invalid_signatureThe recomputed HMAC does not match the header. Either the body was tampered, the wrong secret was supplied, or the signature scheme is unknown.
webhook_invalid_bodyThe body is not valid JSON. (The signature passes only when the body is byte-for-byte what was signed, so the only way to reach this branch is when the upstream sender signed garbage.)
unknown_event_typeThe body verified but carries an event type this SDK version does not know (a newer server). Acknowledge and ignore, or upgrade the SDK. Match-all endpoints should prefer verifyWithMeta with onUnknownType: "raw" (below), which returns the verified payload instead of throwing this.

Manual call (no Express)

For frameworks where the middleware shape doesn’t fit, call verify directly:

import { Junjo, JunjoError } from "@junjo.io/sdk";
 
const junjo = new Junjo({ apiKey: process.env.JUNJO_API_KEY! });
 
async function handle(rawBody: Uint8Array, headers: Record<string, string>) {
  try {
    const event = await junjo.webhooks.verify(
      rawBody,
      headers,
      process.env.JUNJO_WEBHOOK_SECRET!,
    );
    // dispatch on event.type
  } catch (err) {
    if (err instanceof JunjoError) {
      // log err.code; respond 400
    }
    throw err;
  }
}

verifyWithMeta(rawBody, headers, secret, opts?)

Same verification as verify, but returns the delivery identity headers alongside the event so receivers can dedupe retries without re-reading the headers object themselves:

verifyWithMeta(
  rawBody: string | Uint8Array,
  headers: WebhookHeaders,
  secret: string,
  opts?: VerifyWithMetaOptions,
): Promise<VerifiedWebhook | UnknownVerifiedWebhook>;
 
interface VerifyWithMetaOptions extends VerifyOptions {
  onUnknownType?: "throw" | "raw"; // default "throw"
}
 
interface VerifiedWebhook {
  event: JunjoEvent;
  // From x-junjo-event-id: stable per event, shared by every delivery
  // (and retry) of that event. The right key for dedupe.
  eventId: string | undefined;
  // From x-junjo-delivery-id: unique per delivery attempt.
  deliveryId: string | undefined;
}
 
interface UnknownVerifiedWebhook {
  event: null; // the discriminant against VerifiedWebhook
  eventType: string | undefined; // the wire type string, when present
  payload: unknown; // the parsed body verbatim, shape unvalidated
  eventId: string | undefined;
  deliveryId: string | undefined;
}

Both ids are undefined only when an intermediary stripped the header; the signature check is unchanged. Dedupe on eventId (retries share it), not deliveryId (each POST gets a fresh one):

const { event, eventId } = await junjo.webhooks.verifyWithMeta(
  rawBody,
  headers,
  process.env.JUNJO_WEBHOOK_SECRET!,
);
if (eventId && (await alreadyProcessed(eventId))) return; // duplicate delivery

Also exported standalone as verifyWebhookWithMeta.

onUnknownType

Endpoints created with an empty events array receive every event type the server emits, including types added after your SDK version shipped. With the default onUnknownType: "throw", such a delivery throws unknown_event_type, your receiver responds non-2xx, and the delivery worker retries it until you upgrade. Pass onUnknownType: "raw" to have unknown types verify and return instead of throwing:

const result = await junjo.webhooks.verifyWithMeta(
  rawBody,
  headers,
  process.env.JUNJO_WEBHOOK_SECRET!,
  { onUnknownType: "raw" },
);
if (result.event === null) {
  // Verified, but this SDK version predates result.eventType.
  // result.payload is the parsed body verbatim. Acknowledge with 2xx.
} else {
  // result.event is a typed JunjoEvent as usual.
}

The signature and timestamp checks are identical in both modes; only the reaction to an unrecognized type differs. Known-type payloads that fail deserialization still throw. The C++ SDK’s verify_webhook behaves like "raw" unconditionally.

middleware(secret, opts?)

Returns an Express-compatible middleware that verifies, parses, and replaces req.body with the typed JunjoEvent before calling next(). Mount it AFTER express.raw({ type: "application/json" }) so the raw bytes are on req.body.

middleware(secret: string, opts?: VerifyOptions): ExpressLikeMiddleware;

On verification failure the middleware responds 400 with the JunjoError message and does not call next(). The next handler downstream sees req.body as a fully-typed JunjoEvent:

app.post(
  "/webhooks/junjo",
  express.raw({ type: "application/json" }),
  junjo.webhooks.middleware(process.env.JUNJO_WEBHOOK_SECRET!),
  (req, res) => {
    const event = req.body as JunjoEvent;
    switch (event.type) {
      case "member.joined":
        // event.member.userId is typed as UserId
        break;
      case "group.deleted":
        break;
    }
    res.sendStatus(204);
  },
);

If your framework has already parsed the body into a Buffer on a different field (e.g., req.rawBody), the middleware reads from there as a fallback. The lookup order is: req.rawBody first, then req.body (if it is a string or Uint8Array). Anything else and the middleware responds 400 with a hint to add express.raw.

Constants

export const WEBHOOK_SIGNATURE_SCHEME = "v1";
export const WEBHOOK_DEFAULT_TOLERANCE_MS = 5 * 60_000;

WEBHOOK_SIGNATURE_SCHEME is the prefix Junjo writes ahead of the hex digest. Future schemes (e.g., v2=...) will coexist and the verifier will route on this prefix. WEBHOOK_DEFAULT_TOLERANCE_MS is the default replay-protection window; override per call via opts.tolerance.

endpoints

CRUD for the webhook endpoints Junjo POSTs to. Endpoints are scoped per game; the calling API key determines which game the endpoint belongs to. Mirror counterpart of the /v1/webhooks API routes.

class WebhookEndpointsApi {
  create(input: CreateWebhookEndpointInput): Promise<WebhookEndpointWithSecret>;
  list(opts?: { limit?: number; cursor?: string }): Promise<Page<WebhookEndpoint>>;
  listAll(opts?: { limit?: number }): AsyncGenerator<WebhookEndpoint>;
  update(id: WebhookEndpointId, input: UpdateWebhookEndpointInput): Promise<WebhookEndpoint>;
  delete(id: WebhookEndpointId): Promise<void>;
}
 
interface CreateWebhookEndpointInput {
  url: string;
  events?: JunjoEventType[];
  secret?: string;
  format?: WebhookEndpointFormat;
}
 
interface UpdateWebhookEndpointInput {
  url?: string;
  events?: JunjoEventType[];
  disabled?: boolean;
  format?: WebhookEndpointFormat;
}
 
type WebhookEndpointFormat = "junjo" | "discord" | "slack";

endpoints.create(input)

Creates a webhook endpoint and returns it including the signing secret. The secret is returned exactly once; persist it server-side immediately. Subsequent list and update calls do not surface it again.

const endpoint = await junjo.webhooks.endpoints.create({
  url: "https://dev.example.com/webhooks/junjo",
  events: ["member.joined", "group.deleted"],
});
console.log(endpoint.secret); // store this in your secret manager NOW

If secret is omitted (the common case), the server generates a 32-byte base64url secret. To rotate to a known string (e.g. recovering after a leak by re-creating with a fresh value), pass secret. The minimum length is 16; the maximum is 256.

events defaults to [] (match-all). Pass a non-empty array to filter to specific event types; unknown event types are rejected with a 400.

format defaults to "junjo" (the raw JunjoEvent JSON with HMAC headers, the format verify and middleware consume above). Pass "discord" to translate events into Discord embed payloads or "slack" to produce a Slack Block Kit message before POSTing; the URL is then a provider-issued webhook URL, the HMAC headers are skipped, and verify / middleware are not used (the provider receives the delivery directly).

// Pipe Junjo events to a Discord channel
await junjo.webhooks.endpoints.create({
  url: "https://discord.com/api/webhooks/123/abc...",
  format: "discord",
  events: ["member.joined", "member.left", "group.deleted"],
});
 
// Pipe the same events to a Slack channel
await junjo.webhooks.endpoints.create({
  url: "https://hooks.slack.com/services/T0/B0/abc...",
  format: "slack",
  events: ["member.joined", "member.left", "group.deleted"],
});

endpoints.list(opts?)

Returns one page of the calling game’s endpoints, newest first, in the standard Page<WebhookEndpoint> shape ({ items, nextCursor }). The wire format omits secret. limit defaults to 50 (max 100); nextCursor is the id of the last item, fed back as cursor for the next page, and null on the final page.

const page = await junjo.webhooks.endpoints.list();
for (const e of page.items) {
  if (e.disabledAt) console.log(`${e.id} is disabled`);
}

endpoints.listAll(opts?)

Async-iterator wrapper over list that walks every page until nextCursor is null. Endpoint counts are small in practice, but this keeps the surface symmetric with the other paginated lists.

for await (const e of junjo.webhooks.endpoints.listAll()) {
  // ...
}

endpoints.update(id, input)

Partial update. Pass at least one of url, events, or disabled; an empty body is rejected.

// Disable an endpoint without deleting it (e.g. while debugging a 5xx loop)
await junjo.webhooks.endpoints.update(endpoint.id, { disabled: true });
 
// Narrow the event filter
await junjo.webhooks.endpoints.update(endpoint.id, {
  events: ["group.deleted"],
});
 
// Re-enable
await junjo.webhooks.endpoints.update(endpoint.id, { disabled: false });

events replaces wholesale; pass [] to clear the filter back to match-all. The post-state WebhookEndpoint is returned (without secret). A PATCH whose values match the stored row is idempotent (no DB write).

endpoints.delete(id)

Hard-deletes the endpoint. Pending WebhookDelivery rows are cascaded by the database. Returns void. A second delete on the same id throws JunjoError with code: "not_found".

await junjo.webhooks.endpoints.delete(endpoint.id);

Receiver requirements

  • Receivers MUST be idempotent on x-junjo-event-id. The worker delivers at-least-once: a crash between POST and row update can re-deliver an event. Use a unique constraint on eventId in your receiver-side store.
  • Receivers MUST verify the signature on the raw body, before JSON.parse. Parsing then re-stringifying the body changes its bytes (key order, whitespace) and breaks the HMAC. The middleware does this in the right order; manual callers should pass the raw Uint8Array straight from the HTTP framework.
  • Tolerance is bidirectional. The verifier rejects timestamps too old (replay) AND too far in the future (clock skew or sender attack).