DEV Community

Cover image for A Replay-Safe Cypress Cloud Webhook Receiver in TypeScript
Raju Dandigam
Raju Dandigam

Posted on

A Replay-Safe Cypress Cloud Webhook Receiver in TypeScript

Cypress Cloud can send a run.completed webhook when a recorded run reaches a final state. The tempting implementation is:

app.post('/webhook', (req, res) => handle(req.body))
Enter fullscreen mode Exit fullscreen mode

That misses the hard parts: authenticity, replay protection, duplicate delivery, partial failure, and the ten-second response deadline.

Here is the safer shape.

1. Verify the exact raw body

Cypress signs ${timestamp}.${rawBody} with HMAC-SHA256. JSON parsing can change the bytes, so register a raw parser on this route before express.json().

import crypto from 'node:crypto'
import express from 'express'

const app = express()
const secret = process.env.CYPRESS_WEBHOOK_SECRET

if (!secret) throw new Error('Missing CYPRESS_WEBHOOK_SECRET')

app.post(
  '/webhooks/cypress',
  express.raw({ type: 'application/json', limit: '256kb' }),
  async (req, res) => {
    const signature = req.get('X-Cypress-Signature') ?? ''
    const timestamp = req.get('X-Cypress-Timestamp') ?? ''
    const eventType = req.get('X-Cypress-Event') ?? ''
    const eventVersion = req.get('X-Cypress-Event-Version') ?? ''
    const eventId = req.get('X-Cypress-Event-Id') ?? ''

    if (!signature || !timestamp || !eventType || !eventVersion || !eventId) {
      return res.status(400).send('Missing headers')
    }

    const age = Math.abs(Date.now() / 1000 - Number(timestamp))
    if (!Number.isFinite(age) || age > 300) {
      return res.status(400).send('Stale request')
    }

    const expected =
      'sha256=' +
      crypto
        .createHmac('sha256', secret)
        .update(`${timestamp}.${req.body.toString('utf8')}`)
        .digest('hex')

    const actualBytes = Buffer.from(signature)
    const expectedBytes = Buffer.from(expected)
    const valid =
      actualBytes.length === expectedBytes.length &&
      crypto.timingSafeEqual(actualBytes, expectedBytes)

    if (!valid) return res.status(401).send('Invalid signature')

    if (eventType !== 'run.completed' || eventVersion !== '1') {
      return res.status(422).send('Unsupported event type or version')
    }

    const event = JSON.parse(req.body.toString('utf8'))
    const inserted = await storeEventOnce(eventId, event)

    return res.status(inserted ? 202 : 200).send(
      inserted ? 'accepted' : 'duplicate',
    )
  },
)

app.use(express.json())
Enter fullscreen mode Exit fullscreen mode

HMAC proves authenticity and integrity; it does not encrypt the payload. The endpoint still needs HTTPS.

The signature check should happen before schema validation or business routing. Otherwise an attacker can use the parser and downstream code even though the request was never authenticated. Keep the route-specific raw parser ahead of the application's general JSON parser, and reject a payload that is too large before doing expensive work.

The relevant headers have distinct jobs:

Header Use
X-Cypress-Signature Authenticate the raw bytes
X-Cypress-Timestamp Reject an old signed replay
X-Cypress-Event Route the payload to the correct handler
X-Cypress-Event-Version Select and validate the payload schema
X-Cypress-Event-Id Deduplicate the logical event
X-Cypress-Request-Id Correlate one delivery attempt
X-Cypress-Idempotency-Key Stable delivery identity supplied by Cypress

2. Deduplicate on the event ID

X-Cypress-Event-Id remains stable across automatic retries and manual redelivery. X-Cypress-Request-Id changes on every attempt, so it is the wrong deduplication key.

Persist the event and an outbox job atomically:

async function storeEventOnce(eventId: string, payload: unknown) {
  return db.transaction(async (tx) => {
    const created = await tx.webhookEvents.insertIfAbsent({
      eventId,
      payload,
      receivedAt: new Date(),
    })

    if (!created) return false

    await tx.outbox.insert({
      key: eventId,
      topic: 'cypress.run.completed',
      payload,
    })

    return true
  })
}
Enter fullscreen mode Exit fullscreen mode

Without the transaction, a crash between “mark processed” and “enqueue work” can either lose the action or run it twice.

3. Return quickly

Cypress Cloud times out an attempt after ten seconds. It retries network failures, 408, 429, and 5xx responses, up to ten total attempts with exponential backoff and jitter.

Do not create tickets, fetch replay data, call an LLM, or post several notifications in the request handler. Verify, persist, enqueue, and acknowledge. Let workers do slow work with their own retry and idempotency policy.

Monitor the boundary separately from the worker. Useful receiver metrics include signature failures, stale requests, duplicate rate, persistence latency, acknowledgement latency, and queue depth. A 202 means the event was durably accepted for work; it does not mean the ticket, notification, or triage job completed.

Keep container state disposable

Running the receiver and worker as separate Docker services is useful because they scale and fail differently:

services:
  receiver:
    build: .
    command: ["node", "dist/receiver.js"]
  worker:
    build: .
    command: ["node", "dist/worker.js"]
  postgres:
    image: postgres@sha256:<approved-digest>
    volumes:
      - webhook-data:/var/lib/postgresql/data

volumes:
  webhook-data:
Enter fullscreen mode Exit fullscreen mode

Do not keep accepted event IDs or outbox jobs only in container memory or on an ephemeral filesystem. A new replica must recognize events accepted by an old one. Inject the webhook secret at runtime through the deployment platform's secret mechanism rather than baking it into the image.

Containers make the runtime replaceable; the database transaction makes the event durable.

4. Read the final status

run.completed covers more than passing and failing:

type RunStatus =
  | 'passed'
  | 'failed'
  | 'errored'
  | 'timedOut'
  | 'cancelled'
Enter fullscreen mode Exit fullscreen mode

A webhook's arrival does not mean “deploy.” It means Cypress Cloud finalized the run. Your release system still needs policy for branch, commit SHA, required groups, flaky tests, and other gates.

The Run Completion API can reduce the normal completion delay after expected groups finish, but it is currently Experimental and never terminates active work. The Run Cancellation API is also Experimental and represents a different intent: stop and mark the run canceled.

Test the failure cases

Use Cypress Cloud's Send test action, choose run.completed, and exercise the signed delivery path with its synthetic payload. Test sends are delivered once and are not retried. Add local tests for:

  • invalid signature;
  • stale timestamp;
  • duplicate event ID;
  • unsupported schema version;
  • persistence failure;
  • worker retry after acknowledgement.

The highest-value assertion is not “returned 200.” It is “sending the same event twice created one durable job.”

Also test the crash boundary: persist the event and outbox row, terminate the worker before delivery, restart it, and confirm that exactly one downstream consequence is eventually recorded. The receiver is only as reliable as the persistence and worker protocol behind its quick acknowledgement.

That is the property that makes downstream automation safe.

References

Top comments (0)