keepcalculator

06 Numbers and Powers

Cypress Cloud Webhooks: Safe TypeScript Handling for Retries

Card No. 06·12834 · Updated · Standard formula, shown below

Attempts remaining 2
Retry progress
0 %
Result
Total retry count
2
You entered
Current attempt (1-based)
1
You entered
You enter
Total retry count, Current attempt (1-based)
You get
Attempts remaining, Retry progress
Rounding
0 dp

Cypress Cloud webhooks let you react to run events, but handling retries safely in TypeScript requires understanding how Cypress structures retry data. This article explains the correct types and patterns to avoid crashes and data corruption when a test retries, and how to make your webhook handler idempotent.

Formula

The calculator above is a helper for thinking about retry counts, not a Cypress feature. The formula for attempts remaining is:

attempts_left = max(0, total_retries – current_attempt + 1)

Where:

  • total_retries – the maximum number of retries configured in Cypress (e.g., retries: 2 means up to 2 retries after the first attempt).
  • current_attempt – which attempt is currently running (1 = original, 2 = first retry, etc.).

The retry progress percentage is (current_attempt – 1) / total_retries * 100 – it shows how many retries have been consumed.

Worked Example

With the default values (total_retries = 2, current_attempt = 1):

  • attempts_left = max(0, 2 – 1 + 1) = 2
  • retry_ratio = (1 – 1) / 2 * 100 = 0%

So on the first attempt, you have 2 retries left and 0% of your retry budget is used. If current_attempt = 2, attempts_left = 1 and retry_ratio = 50%.

Background: How Cypress Cloud Webhooks Work

Cypress Cloud (formerly Cypress Dashboard) can send HTTP POST requests to a URL you specify when certain events occur – like when a run starts, when a test finishes, or when a run completes. The payload is JSON, and Cypress signs it with a secret so you can verify it came from Cypress.

The key thing about retries is that Cypress Cloud sends a webhook for each attempt of a test. So if a test fails and retries twice, you’ll receive three webhook events for that test – one for the original failure, one for the first retry, and one for the final result. Each payload contains a retry field that tells you which attempt this is (0 for the original, 1 for the first retry, etc.).

This is different from how you might expect it to work – you don’t get a single “test finished” event after all retries are done. You get one event per attempt. That’s the core reason why naive TypeScript handlers break: they assume one event per test.

Why Naive TypeScript Handling Breaks

Consider a simple handler that updates a database record when a test fails:

// BAD: overwrites the record every time
const onTestFailed = async (payload: TestFailedPayload) => {
  await db.testResults.update({
    where: { id: payload.testId },
    data: { status: 'failed', error: payload.error },
  });
};

With retries, this runs three times. The first failure overwrites the record, then the retry succeeds – but the webhook for the retry might arrive after the original failure webhook. Your database now shows a failure even though the test eventually passed.

The problem is that the payload doesn’t tell you whether this is the final attempt. You need to look at the retry field and the totalRetries field (if present) to know if this is the last attempt. In Cypress Cloud’s webhook payload, the retry field is a number starting at 0. The totalRetries field is not always present – it depends on the event type. For run completion events, you’ll get a summary with totalFailed and totalPassed but not per-test retry counts.

Another common issue is TypeScript type errors. The official Cypress types for webhooks are not published as an npm package – you have to define them yourself. Many developers copy types from blog posts that are outdated or wrong, leading to runtime errors when a field is actually null or missing.

Safe TypeScript Types for Webhook Payloads

Here’s a minimal set of types that matches the actual Cypress Cloud webhook payload (as of Cypress 12+):

interface CypressWebhookBase {
  runId: string;
  runUrl: string;
  runStartedAt: string;
  runCompletedAt: string | null;
  totalPassed: number;
  totalFailed: number;
  totalSkipped: number;
  totalRetries: number; // total retries across all tests
}

interface TestResultWebhook extends CypressWebhookBase {
  event: 'test:failed' | 'test:passed' | 'test:skipped';
  testId: string;
  testTitle: string;
  testFile: string;
  retry: number; // 0 for original, 1 for first retry, etc.
  error: string | null;
}

interface RunCompletedWebhook extends CypressWebhookBase {
  event: 'run:completed';
  runUrl: string;
  // No retry field – this is the aggregate event
}

Key points:

  • Use string | null for optional fields like error – Cypress will send null, not omit the field.
  • Don’t assume retry exists on all events. On run:completed there is no retry field.
  • Type the event as a union so you can narrow the payload based on the event type.

When you receive a webhook, validate the payload at runtime. Use a library like Zod or io-ts to parse the JSON and throw if it doesn’t match. This prevents silent type mismatches from corrupting your data.

How to Handle Retries Safely

The safe pattern is to treat each webhook as an event, not as a state update. Instead of overwriting a record, you append a new record for each attempt. Then, when you want to know the final result, you query the latest attempt.

Here’s a concrete example:

// GOOD: append-only, idempotent
const onTestResult = async (payload: TestResultWebhook) => {
  // Use a unique key to avoid duplicates if Cypress retries the webhook
  const id = `${payload.runId}-${payload.testId}-${payload.retry}`;
  await db.testAttempts.upsert({
    where: { id },
    create: {
      id,
      runId: payload.runId,
      testId: payload.testId,
      retry: payload.retry,
      status: payload.event === 'test:passed' ? 'passed' : 'failed',
      error: payload.error,
      receivedAt: new Date(),
    },
    update: {}, // if the same attempt arrives again, do nothing
  });
};

This is idempotent – if Cypress retries the webhook delivery (which it does, up to a few times), you won’t create duplicate records. The upsert uses a composite key of run, test, and retry attempt.

To determine the final status of a test, you’d query:

const finalAttempt = await db.testAttempts.findFirst({
  where: { runId, testId },
  orderBy: { retry: 'desc' },
});
// finalAttempt.status is the final result

This works because Cypress sends attempts in order, but the webhook delivery order is not guaranteed. By storing each attempt and then picking the highest retry number, you get the correct final state.

Common Problems and Solutions

Here are the most frequent issues developers hit:

  • Missing retry field on run-completed events. Solution: check the event field first and only access retry when the event is test-level.
  • Webhook retries causing duplicate processing. Solution: make your handler idempotent using a unique key (as above) or by checking if the event ID has already been processed.
  • TypeScript errors when a field is null. Solution: always use string | null and check for null before using the value.
  • Assuming the last webhook for a test is the final result. Solution: don’t rely on order – use the retry number to determine finality.
  • Not verifying the webhook signature. Cypress signs webhooks with a secret. You should verify the signature to prevent fake requests. Use the cypress-webhook-verification pattern (see Cypress docs) – it’s a HMAC-SHA256 of the raw body.

Another common misconception is that totalRetries in the run summary tells you how many retries a specific test had. It doesn’t – it’s the total across all tests. To get per-test retry counts, you need to count the number of webhook events for that test.

How to Choose the Right Handling Strategy

Your choice depends on what you’re doing:

  • If you only care about the final result (e.g., updating a badge), use the run:completed event – it has the aggregate counts. But note it doesn’t include per-test details, so you can’t know which tests failed after retries.
  • If you need per-test final status (e.g., a test report), store each attempt and query the latest as shown above.
  • If you need to track retries in real-time (e.g., a dashboard), process each test-level event and update a counter.

For most use cases, the append-only approach is safest because it’s resilient to out-of-order delivery and webhook retries. It also gives you a full audit trail of what happened.

Costs and Alternatives

Handling webhooks yourself has no direct monetary cost beyond your server and database. However, if you’re using a serverless function, each webhook invocation costs a small amount. With retries, you’ll get more invocations than you might expect – a run with 100 tests and 2 retries each could send up to 300 test-level webhooks. That’s fine for most platforms, but it’s worth knowing.

Alternatives to building your own webhook handler:

  • Use Cypress Cloud’s built-in integrations – it can post to Slack, Jira, and other tools without any code.
  • Use a third-party service like Zapier or IFTTT to receive webhooks and forward them – but you still need to handle retries correctly in your downstream steps.
  • Poll the Cypress Cloud API instead of using webhooks. The API gives you a complete run result with retry details. This is simpler but not real-time.

If you’re just starting, consider polling the API for run completion – it’s much easier to reason about. Webhooks are best when you need immediate reaction to test failures.

Frequently Asked Questions

Does Cypress Cloud send a webhook for every retry attempt?

Yes. For test-level events, Cypress Cloud sends a separate webhook for each attempt – the original attempt and each retry. The retry field in the payload indicates which attempt it is (0 for original, 1 for first retry, etc.).

How do I know if a test finally passed after retries?

You can’t know from a single test-level webhook alone. You need to wait for all attempts to arrive (or use the run:completed event) and then look at the highest retry number for that test. If the last attempt’s status is ‘passed’, the test passed.

What if webhooks arrive out of order?

That’s possible. The append-only pattern with a unique key solves this – you store each attempt and later query the highest retry number. Never assume the last webhook you receive is the final attempt.

Is there an official TypeScript type package for Cypress webhooks?

No. Cypress does not publish official TypeScript types for webhook payloads. You need to write your own or use community types. Always validate the payload at runtime to catch discrepancies.

How do I verify that a webhook is really from Cypress?

Cypress signs each webhook with a secret you provide. Compute the HMAC-SHA256 of the raw request body using your secret and compare it to the X-Cypress-Webhook-Signature header. If they don’t match, reject the request. Details are in the Cypress Cloud documentation.

Can I use the run:completed event instead of test-level events?

Yes, if you only need aggregate results. The run:completed event includes total passed, failed, and skipped counts, but not per-test retry details. For per-test information, you must use test-level events.

Related

Formula used
in t | Total retry count | - | 2 | 0 | 10
in r | Current attempt (1-based) | - | 1 | 1 | 10
out attempts_left | Attempts remaining | - | max(0, t - r + 1) | 0
out retry_ratio | Retry progress | % | (r - 1) / max(1, t) * 100 | 0

Each line is one input (in, sel, list) or one result (out: label, unit, formula, decimals). How the calculators work

Method: the standard formula for this calculation, rounded as each result states. See how the calculators work or report an error.

More cards in this drawer

The whole Numbers and Powers drawer →