junjo.webhooks
Two responsibilities live under this namespace:
endpoints- CRUD for the webhook endpoints Junjo POSTs to.verify/middleware- receiver-side helpers that validate the HMAC signature on inbound deliveries and parse them into typedJunjoEventvalues. Pair with the HTTP wire format docs for the corresponding server side.
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 19+ and modern browsers without node:crypto or @types/node.
import express from "express";
import { Junjo } from "@junjo/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
- Reads
x-junjo-signatureandx-junjo-timestampfrom the supplied headers (case-insensitive; arrays are accepted and the first element wins). - Validates the timestamp is a parseable ISO 8601 string.
- Validates the timestamp is within
toleranceofnow. The default is 5 minutes; raise it only if your clocks are known to drift. - Computes HMAC-SHA256 of
<timestamp>.<body>withsecretand constant-time compares against thex-junjo-signatureheader (which isv1=<hex>). - JSON.parses the body and rehydrates
Datefields and branded ids viadeserializeEvent.
Errors
Every failure mode throws JunjoError with status: 400. Branch on error.code:
| Code | When |
|---|---|
webhook_signature_missing | x-junjo-signature header is absent. |
webhook_timestamp_missing | x-junjo-timestamp header is absent. |
webhook_timestamp_invalid | The timestamp is not parseable as ISO 8601. |
webhook_timestamp_out_of_tolerance | Skew exceeds tolerance (default 5 minutes). |
webhook_invalid_signature | The 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_body | The 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.) |
Manual call (no Express)
For frameworks where the middleware shape doesn’t fit, call verify directly:
import { Junjo, JunjoError } from "@junjo/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;
}
}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(): Promise<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 NOWIf 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-reference/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()
Returns every endpoint configured for the calling game, newest first. The wire format omits secret.
const endpoints = await junjo.webhooks.endpoints.list();
for (const e of endpoints) {
if (e.disabledAt) console.log(`${e.id} is disabled`);
}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 oneventIdin 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
Uint8Arraystraight 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).