Gareth Wilson Gareth Wilson

Guide to Currents Webhooks: Features and Best Practices

Published


Currents is a dashboard for Playwright test runs. It collects results from your CI jobs, tracks flaky tests and keeps run history, and its HTTP Webhook integration tells your own systems when a run starts and how it ended. That is enough to post results to a chat channel, update a pull request, open a ticket for a failing build, or hold a deployment until the tests pass.

This guide covers the four events Currents sends and what their payload means, how to authenticate requests with a header you add yourself, the gaps in what Currents documents about delivery, and the best practices for running a Currents receiver in production.

What are Currents webhooks?

The HTTP Webhook integration sends a POST with a JSON body to a URL you choose at four points in a run's life: when it starts, finishes, times out or is cancelled. You add the integration per project, and the endpoint has to be publicly reachable.

Currents does not sign the requests. Its docs offer two things for security: custom headers, which you can use to send a secret with every request, and an x-timestamp header carrying the epoch time. The secret header is what authenticates the request. The timestamp is not signed, so anyone who can forge the secret can forge the timestamp too.

Currents webhook features

FeatureDetails
ConfigurationManage Project > Integrations > HTTP Webhook
EventsRUN_START, RUN_FINISH, RUN_TIMEOUT, RUN_CANCELED. Leave Events blank for all four
Method and formatPOST, Content-Type: application/json
DiscriminatorThe body's event field
Multi-group runsOne request per group, each with its own groupId
AuthenticationA custom header and secret value you add. No signature
Timestampx-timestamp header with the epoch time, unsigned
Timeouts and retriesNot documented
Source IPsNot published

Events and payload

EventFires when
RUN_STARTA new run starts
RUN_FINISHA run finishes
RUN_TIMEOUTA run times out
RUN_CANCELEDA run is cancelled

Note the spelling: RUN_CANCELED with one L. All four events share one flat shape. Here is Currents' RUN_FINISH example:

{
  "event": "RUN_FINISH",
  "runUrl": "https://app.currents.dev/run/950a13c3edf4f28b6e8ce301a404b4aa",
  "buildId": "demo-build-cdx3314",
  "groupId": "chromium",
  "tags": ["smoke", "production"],
  "commit": {
    "authorEmail": "john@doe.com",
    "authorName": "John Doe",
    "defaultBranch": "main",
    "branch": "feature-A",
    "message": "feat: change button color",
    "remoteOrigin": "https://github.com/org/repo.git",
    "sha": "5a7edc9a156e417068060d67109021351fc7d9b8"
  },
  "overall": 24,
  "passes": 21,
  "failures": 2,
  "pending": 0,
  "skipped": 1,
  "retries": 3,
  "flaky": 1
}

buildId is the build ID your CI reported, and groupId is only present for runs with several groups, such as several Playwright projects reported under one ci-build-id. Each group sends its own request, so one CI build can produce several RUN_FINISH events. Every field in commit can be null. tags holds the run's tags and the tags on the tests and groups that ran.

The counters need care. passes, failures, pending and skipped add up to overall, which counts the tests that reported a result. pending is tests that never ran, such as test.skip(), and skipped is tests that didn't run because of a hook error or an earlier failure in a serial group. flaky overlaps the others: a flaky test is also counted under its final outcome. RUN_START, RUN_TIMEOUT and RUN_CANCELED carry only the results reported so far, so overall in a timed-out or cancelled run is smaller than the number of tests the run set out to execute. A RUN_CANCELED payload is a snapshot: tests already in progress still finish and Currents accepts their results, so the counters can change after the request is sent.

Inspect Currents webhooks as they arrive. Point the integration at a Hookdeck Inspect URL to see the exact headers and body Currents sends, with no account or setup required.

Setting up Currents webhooks

Generate a long random secret first, for example with openssl rand -hex 32, and decide which header it will travel in.

  1. In Currents, open the project and go to Manage Project > Integrations.
  2. Add the HTTP Webhook integration.
  3. Enter your endpoint as the URL.
  4. Under Events, pick the events you want, or leave it blank for all four.
  5. Under Headers, add your secret header, such as X-Currents-Secret with your secret as the value. Whatever you type is sent as-is, so include Bearer if you use Authorization and want a bearer token.
  6. Click Save.

To put Hookdeck in front of your service, create a Hookdeck source with the Currents type and use its URL in step 3. The source asks for a Header Name and a Header Value: enter the same name and the whole value you typed in Currents. Header names match case-insensitively, but the value must match exactly, prefix included. The source accepts POST, which is the only method Currents uses.

For local development, hookdeck listen 3000 currents --path /webhooks/currents gives you a public URL that forwards to your machine.

Securing Currents webhooks

Read the header you chose and compare its whole value with your stored secret in constant time. Reject the request if the header is missing or the secret isn't configured.

const crypto = require('crypto');
const express = require('express');

const HEADER_NAME = process.env.CURRENTS_WEBHOOK_HEADER || 'x-currents-secret';
const SECRET = process.env.CURRENTS_WEBHOOK_SECRET;

function safeEqual(received, expected) {
  const a = Buffer.from(received, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  // timingSafeEqual throws on unequal lengths, so check first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

app.post('/webhooks/currents', express.raw({ type: '*/*' }), (req, res) => {
  const received = req.get(HEADER_NAME); // case-insensitive lookup
  if (!SECRET || typeof received !== 'string' || !safeEqual(received, SECRET)) {
    return res.sendStatus(401);
  }

  let run;
  try {
    run = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }

  switch (run.event) {
    case 'RUN_FINISH':
      // Final counters for this run or group: run.failures, run.flaky, run.runUrl
      break;
    case 'RUN_TIMEOUT':
    case 'RUN_CANCELED':
      // Partial results only; read the run from Currents if you need final numbers
      break;
    case 'RUN_START':
      break;
  }

  res.sendStatus(200);
});

The same check in Python:

import hmac
import os

HEADER_NAME = os.environ.get("CURRENTS_WEBHOOK_HEADER", "X-Currents-Secret")
SECRET = os.environ.get("CURRENTS_WEBHOOK_SECRET", "")


def verify_currents_request(headers) -> bool:
    if not SECRET:
        return False  # never accept when unconfigured
    received = headers.get(HEADER_NAME)  # Starlette and Flask headers are case-insensitive
    if not received:
        return False
    # Compare bytes: compare_digest raises TypeError on a non-ASCII str.
    return hmac.compare_digest(received.encode("utf-8"), SECRET.encode("utf-8"))

A static header proves the sender knows the secret. It does not cover the body, so it cannot detect a modified payload, and x-timestamp is not signed, so it is not real replay protection. If you want a freshness check anyway, compare x-timestamp with your clock in your own receiver, and keep your server's clock synchronised as Currents recommends. Hookdeck does not check it. Serve the endpoint over HTTPS, use a long random secret, and rotate it if it might have leaked.

Make Currents webhooks production-ready. Hookdeck Event Gateway checks the secret header you configure, stores every run event, and lets you retry and replay deliveries on your own schedule.

Currents webhook limitations and pain points

A secret header is all you get

The Problem: Nothing in a Currents request proves the body is unchanged, and the timestamp that looks like replay protection can be forged along with everything else.

Why It Happens: Currents offers custom headers and an unsigned x-timestamp. It computes no signature.

Workarounds:

  • Use a long random secret over HTTPS, and keep it out of logs.
  • Treat a webhook as a notification for high-stakes actions, such as unblocking a deployment, and confirm the run's status through Currents before acting.

How Hookdeck Can Help: Hookdeck's Currents source compares the header you name with the exact value you enter. Hookdeck still answers with a 200, but a request with a missing or wrong value is recorded as failed verification and never reaches your destination.

Delivery behaviour is undocumented

The Problem: If your endpoint is down when a run finishes, you don't know whether Currents will try again, and there is no documented way to resend a missed event.

Why It Happens: The integration's docs describe the events, the payload and the headers, and say nothing about timeouts, retries or redelivery.

Workarounds:

  • Keep the endpoint available and fast, and return 200 as soon as the request is authenticated.
  • For anything that must not be missed, such as deployment gates, also poll Currents for the run's state.

How Hookdeck Can Help: Hookdeck accepts and stores each request as it arrives, then delivers it to your service with retries you configure. You can replay any event after a fix, so a deploy or outage on your side does not lose the event.

One build, many requests

The Problem: A handler that posts "build finished" on every RUN_FINISH posts several times for one CI build.

Why It Happens: Runs with several groups emit one request per group, each with its own groupId and its own counters.

Workarounds:

  • Key your processing on buildId plus groupId, and aggregate if you need build-level totals.
  • Treat a missing groupId as a single-group run.

How Hookdeck Can Help: Hookdeck filters can route events by event or groupId, so only the groups you care about reach a given destination.

Counters that don't mean what they look like

The Problem: Totals don't add up, or a cancelled run's numbers change after the webhook was sent.

Why It Happens: flaky overlaps the other counters, pending and skipped mean different things, and RUN_START, RUN_TIMEOUT and RUN_CANCELED report only what was known when they were sent.

Workarounds:

  • Sum only passes, failures, pending and skipped to get overall, and report flaky separately.
  • Use RUN_FINISH for final numbers, and read the run through the Currents dashboard or API after a cancellation.

Best practices

Route on event, not on the counters

Branch on the event field first, then read the counters that make sense for it. Log and acknowledge any value you don't recognise rather than returning an error.

Deduplicate

There is no event ID in the payload. Build an idempotency key from runUrl, groupId and event, which together identify one lifecycle event for one group. See our guide to webhook idempotency.

Acknowledge fast and process asynchronously

Posting to chat, updating a pull request or opening a ticket all call other APIs. Authenticate, store the event, return 200, and do that work from a queue. See why to process webhooks asynchronously.

Handle null commit fields

Currents' payload schema types every field in commit as string | null. Use optional access with a fallback, such as run.commit?.branch ?? 'unknown'.

Conclusion

Currents sends four run lifecycle events with one flat payload, one request per group, authenticated only by a header you add yourself. Compare that header's whole value in constant time over HTTPS, route on event, key on runUrl and groupId, and read counters with their overlaps in mind.

Hookdeck Event Gateway checks the secret header, stores every run event, and retries delivery to your service on a schedule you control, which fills the gap Currents leaves undocumented.

Get started with Hookdeck for free and handle Currents webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.