Para agentes de IA: markdown de esta página — /docs-content-en/infra/jobs/signature.md índice de la documentación — /llms.txt
Los artículos de la documentación están disponibles actualmente en inglés.
Verify signatures
Each run is a POST to the configured path, with Content-Type: application/json, X-Vibe-Caller-Kind: schedule and Vibe-Signature. A manual run also uses caller schedule; the trigger field distinguishes manual from schedule. Accept requests only through a trusted platform ingress: close any direct public bypass where a client can set the caller, or enforce the same contract there. The caller alone does not replace a signature.
Bytes and secret
The secret is generated from 32 random bytes and returned as vjs_ plus 43 unpadded base64url characters, 47 characters in total. Use the UTF-8 bytes of the whole string including the prefix as the HMAC key; do not decode the suffix. Keep it only in protected server configuration. It is returned once on creation and rotation, never in list or history.
The signed message is ASCII(t) + b'.' + rawBody, using HMAC-SHA256 with 64 lowercase hex characters. t is the sending time in Unix seconds, new for each attempt. The UTF-8 JSON has no BOM or trailing newline and uses this key order: type, jobId, runId, scheduledFor, attempt, late, trigger. scheduledFor is UTC ISO-8601 with milliseconds; attempt is 1–4; late is the preserved first-attempt assessment (more than 60 seconds late); trigger is schedule or manual.
Verify the exact form of one physical header, freshness abs(now-t) <= 300 seconds, and compare HMAC in constant time. Repeated t/v1, extra fields, uppercase hex, unsafe integers and duplicate headers are rejected. Read rawBody before JSON.parse; reserialization changes the signed bytes. The bounded reader below accepts at most 64 KiB.
Verification code
import { createHmac, timingSafeEqual } from 'node:crypto'
export interface VerifyJobRequestInput {
callerKind: unknown
singleSignatureHeader: unknown
rawBody: Buffer
secret: string
nowSeconds: number
}
export function verifyJobRequest(x: VerifyJobRequestInput): boolean {
if(x.callerKind!=='schedule'||typeof x.singleSignatureHeader!=='string') return false
const m=/^t=(0|[1-9][0-9]{0,15}),v1=([0-9a-f]{64})$/.exec(x.singleSignatureHeader)
if(!m||m[0]!==x.singleSignatureHeader) return false
const t=Number(m[1])
if(!Number.isSafeInteger(t)||!Number.isFinite(x.nowSeconds)||Math.abs(x.nowSeconds-t)>300) return false
const actual=Buffer.from(m[2],'hex')
const expected=createHmac('sha256',Buffer.from(x.secret,'utf8'))
.update(m[1],'ascii').update('.','ascii').update(x.rawBody).digest()
return actual.length===expected.length&&timingSafeEqual(actual,expected)
}
Read HTTP without changing the body
Do not use already joined headers: count physical headers in rawHeaders. HTTP header names are case-insensitive.
import type { IncomingMessage } from 'node:http'
export async function readRawBody(req: IncomingMessage): Promise<Buffer> {
const chunks:Buffer[]=[]; let length=0
for await(const chunk of req){
const b=Buffer.isBuffer(chunk)?chunk:Buffer.from(chunk)
length+=b.length
if(length>65536) throw new Error('BODY_TOO_LARGE')
chunks.push(b)
}
return Buffer.concat(chunks,length)
}
export function singleHeader(req: IncomingMessage,name:string): string|undefined {
const values:string[]=[]
for(let i=0;i<req.rawHeaders.length;i+=2)
if(req.rawHeaders[i].toLowerCase()===name.toLowerCase()) values.push(req.rawHeaders[i+1])
return values.length===1?values[0]:undefined
}
Wire the functions before parsing JSON; req is an IncomingMessage. Keep server clocks synchronized. Reject oversized bodies with 413, bad signature/caller with 401, and invalid payloads with 400. Never log the secret, signature or body.
const rawBody = await readRawBody(req);
const valid = verifyJobRequest({
callerKind: singleHeader(req, 'X-Vibe-Caller-Kind'),
singleSignatureHeader: singleHeader(req, 'Vibe-Signature'),
rawBody,
secret: process.env.JOB_SIGNING_SECRET!,
nowSeconds: Math.floor(Date.now() / 1000),
});
if (!valid) { res.writeHead(401).end(); return; }
const payload = JSON.parse(rawBody.toString('utf8'));
// Validate the payload schema and expected jobId before durable acceptance.
Reference vector
The public test values below are not a production secret. The body is exactly 158 bytes without a newline; the test clock is 1791536400.
secret = vjs_AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8
Vibe-Signature: t=1791536400,v1=47f5954d1810b796a982c0eb461a948f4e80d531212cac1a4668321d24ea3008
{"type":"scheduled_job.run","jobId":"job_vector","runId":"run_vector","scheduledFor":"2026-10-09T09:00:00.000Z","attempt":1,"late":false,"trigger":"schedule"}
Secret rotation
Every new attempt uses the current secret. An already sent attempt may carry the old signature: the app can keep its previous key for a short 5-minute window and verify against both keys, or accept a refusal. Rotation does not cancel RUNNING, resign a sent request or recover permanent app-401/403. The app manages the previous key and its removal time itself.
Durable deduplication and outbox
After signature verification, atomically accept runId and enqueue durable work. All handler replicas use the same database. An in-memory Map/Set or a check-then-insert flow permits duplicate business operations. The PostgreSQL example below returns 202 only after commit.
CREATE TABLE accepted_runs (
run_id text PRIMARY KEY,
accepted_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz,
response_status integer NOT NULL CHECK (response_status BETWEEN 200 AND 299),
response_body jsonb NOT NULL
);
CREATE TABLE job_outbox (
run_id text PRIMARY KEY REFERENCES accepted_runs(run_id),
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz
);
$1 is the validated runId; $2 is the validated payload JSON. Bind driver parameters instead of interpolating strings into SQL. Execute all commands on one connection; on failure ROLLBACK and do not return 2xx. The separate SELECT after INSERT reads the saved response, including after a concurrent conflict.
BEGIN;
WITH accepted AS (
INSERT INTO accepted_runs(run_id, response_status, response_body)
VALUES ($1, 202, '{"accepted":true}'::jsonb)
ON CONFLICT (run_id) DO NOTHING
RETURNING run_id
)
INSERT INTO job_outbox(run_id, payload)
SELECT run_id, $2::jsonb FROM accepted;
-- A separate statement sees a concurrent winner's committed receipt.
SELECT response_status, response_body FROM accepted_runs WHERE run_id = $1;
COMMIT;
Return the saved response_status/response_body after a successful commit; a repeat of an accepted runId creates no second outbox item and returns the same 2xx. A crash after commit but before responding is safe to retry. An outbox worker claims work using a lock/lease and marks processed_at after completion. For external effects use runId as the receiver's idempotency key: an outbox alone cannot guarantee exactly one external effect after a crash.
Keep acceptance receipts for at least the platform history retention: 90 days by default, at least 7 days, longer than the 96-hour delivery lifetime. Coordinate the retention setting with the platform; never delete an acceptance record or outbox item for unfinished work. Clean up only processed records after that period, outbox first, then accepted_runs. Signature freshness limits an old prepared request to five minutes, but new attempts of the same runId may arrive until deliveryDeadlineAt.
The digest example connects verification, enqueuing and observing completion.