junjo.webhooks
Two responsibilities live under this namespace:
endpoints: CRUD for the webhook endpoints Junjo POSTs to.verify/verifyWithMeta/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.
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
- 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.) |
unknown_event_type | The 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 deliveryAlso 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 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/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 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).