CorrConnectDocs
Early accessRequest access

Guides

Receive webhooks

Webhooks are free and save your rate limit. CorrConnect posts a small signed event when an athlete adds an activity or your access ends; you fetch the details with the athlete's token.

1. Build the endpoint

Your endpoint must be a public https:// URL on port 443. CorrConnect looks up the host name for each delivery, refuses private, loopback, link-local and carrier-grade NAT addresses, and connects to the address it checked while keeping your host name for TLS. It does not follow redirects; a 3xx counts as a failure. There is no published list of sending addresses to allow. A delivery is genuine when its signature checks out, so check every one.

For every request, in this order:

  1. Read the raw body as bytes. Don't parse and re-serialize it before checking the signature.
  2. Verify CorrConnect-Signature against the raw body. Reject anything that fails with a 400.
  3. Drop it if you've already handled this CorrConnect-Event-Id.
  4. Queue the work and answer 204 (any 2xx works) within 10 seconds. That deadline covers the whole request, connect to last byte.
  5. Process the queue: fetch the activity, or delete the athlete's tokens.

2. Verify the signature

The header looks like t=1760011200,v1=5f1c.... v1 is the lowercase hex HMAC-SHA256 of the timestamp, a full stop and the raw body, keyed with your subscription's secret:

Text
v1 = hex(HMAC_SHA256(secret, "<t>." + raw_body))

Compare in constant time, and reject a t more than 5 minutes (300 seconds) from your clock so an old delivery can't be replayed.

verify.mjs
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyCorrConnectSignature(secret, header, rawBody, nowSeconds = Date.now() / 1000) {
  const fields = Object.fromEntries(
    (header ?? "").split(",").map((part) => {
      const index = part.indexOf("=");
      return [part.slice(0, index), part.slice(index + 1)];
    }),
  );
  const timestamp = Number(fields.t);
  if (!Number.isInteger(timestamp) || Math.abs(nowSeconds - timestamp) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest();
  const received = Buffer.from(fields.v1 ?? "", "hex");
  return received.length === expected.length && timingSafeEqual(received, expected);
}
verify.py
import hashlib
import hmac
import time


def verify_corrconnect_signature(secret: str, header: str, raw_body: bytes, now: float | None = None) -> bool:
    try:
        fields = dict(part.split("=", 1) for part in header.split(","))
        timestamp = int(fields["t"])
        received = fields["v1"]
    except (KeyError, ValueError):
        return False
    if abs((time.time() if now is None else now) - timestamp) > 300:
        return False
    expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, received)
Verify.swift
import CryptoKit
import Foundation

func verifyCorrConnectSignature(secret: String, header: String, rawBody: Data, now: Date = Date()) -> Bool {
    var fields: [Substring: Substring] = [:]
    for part in header.split(separator: ",") {
        let pair = part.split(separator: "=", maxSplits: 1)
        if pair.count == 2 { fields[pair[0]] = pair[1] }
    }
    guard let t = fields["t"], let timestamp = Int(t), let v1 = fields["v1"], v1.count == 64,
          abs(Int(now.timeIntervalSince1970) - timestamp) <= 300 else { return false }
    var signature = Data()
    var index = v1.startIndex
    while index < v1.endIndex {
        let next = v1.index(index, offsetBy: 2)
        guard let byte = UInt8(v1[index..<next], radix: 16) else { return false }
        signature.append(byte)
        index = next
    }
    var message = Data("\(timestamp).".utf8)
    message.append(rawBody)
    return HMAC<SHA256>.isValidAuthenticationCode(signature, authenticating: message,
                                                  using: SymmetricKey(data: Data(secret.utf8)))
}

3. A complete receiver

receiver.mjs
import { createServer } from "node:http";
import { verifyCorrConnectSignature } from "./verify.mjs";

const seen = new Set(); // use your database in production

createServer((request, response) => {
  if (request.method !== "POST" || request.url !== "/hooks/corrconnect") {
    response.writeHead(404).end();
    return;
  }
  const chunks = [];
  request.on("data", (chunk) => chunks.push(chunk));
  request.on("end", () => {
    const rawBody = Buffer.concat(chunks);
    const signature = request.headers["corrconnect-signature"];
    if (!verifyCorrConnectSignature(process.env.WEBHOOK_SECRET, signature, rawBody)) {
      response.writeHead(400).end();
      return;
    }
    const eventId = request.headers["corrconnect-event-id"];
    if (!seen.has(eventId)) {
      seen.add(eventId);
      const event = JSON.parse(rawBody);
      setImmediate(() => handle(event)); // answer first, work after
    }
    response.writeHead(204).end();
  });
}).listen(Number(process.env.PORT ?? 8787));

function handle(event) {
  if (event.type === "activity.created") {
    console.log("fetch", event.data.activity_id, "for", event.subject);
  } else if (event.type === "grant.revoked") {
    console.log("forget tokens for", event.subject);
  }
}
receiver.py
import json
import os

from fastapi import BackgroundTasks, FastAPI, Request, Response

from verify import verify_corrconnect_signature

app = FastAPI()
seen: set[str] = set()  # use your database in production


def handle(event: dict) -> None:
    if event["type"] == "activity.created":
        print("fetch", event["data"]["activity_id"], "for", event["subject"])
    elif event["type"] == "grant.revoked":
        print("forget tokens for", event["subject"])


@app.post("/hooks/corrconnect", status_code=204)
async def corrconnect_webhook(request: Request, background: BackgroundTasks) -> Response:
    raw_body = await request.body()
    signature = request.headers.get("CorrConnect-Signature", "")
    if not verify_corrconnect_signature(os.environ["WEBHOOK_SECRET"], signature, raw_body):
        return Response(status_code=400)
    event_id = request.headers["CorrConnect-Event-Id"]
    if event_id not in seen:
        seen.add(event_id)
        background.add_task(handle, json.loads(raw_body))  # runs after the response is sent
    return Response(status_code=204)

4. Subscribe

There is no webhook screen in the portal yet, so subscriptions are managed through the console session routes. Sign in, then create one:

cURL
# Sign in and keep the session cookie
curl -c cookies.txt "$CORRCONNECT_BASE/v1/account/login" \
  -H "Origin: $CORRCONNECT_BASE" \
  -H "Content-Type: application/json" \
  -d '{"email": "[email protected]", "password": "<your-password>"}'
# The response includes csrf_token
export CSRF_TOKEN="csrf_..."

curl -b cookies.txt "$CORRCONNECT_BASE/v1/developer/apps/$APP_ID/webhooks" \
  -H "Origin: $CORRCONNECT_BASE" \
  -H "X-CSRF-Token: $CSRF_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"callback_url": "https://developer.example/hooks/corrconnect"}'
subscribe.py
import getpass
import os

import requests

base = os.environ["CORRCONNECT_BASE"]
session = requests.Session()
session.headers["Origin"] = base

signed_in = session.post(f"{base}/v1/account/login", timeout=10, json={
    "email": os.environ["CORRCONNECT_EMAIL"],
    "password": os.environ.get("CORRCONNECT_PASSWORD") or getpass.getpass(),
})
signed_in.raise_for_status()
session.headers["X-CSRF-Token"] = signed_in.json()["csrf_token"]

created = session.post(f"{base}/v1/developer/apps/{os.environ['APP_ID']}/webhooks", timeout=10,
                       json={"callback_url": os.environ["WEBHOOK_URL"]})
created.raise_for_status()
subscription = created.json()
print("subscription", subscription["id"], subscription["events"])
print("store this secret now, it is not shown again:", subscription["secret"])

The response includes secret (whsec_...) once. Store it where your receiver can read it; CorrConnect never shows it again. To rotate a secret, create a second subscription, move your receiver to the new secret, then delete the old one. An app can have 3 subscriptions.

Leave out events to get both activity.created and grant.revoked, or pass one of them.

5. Send a test

POST /v1/developer/apps/{app_id}/webhooks/{subscription_id}/test queues a webhook.test event, signed like a real one, with subject: null and empty data. Check your logs and the delivery log.

Retries and the delivery log

A delivery that doesn't get a 2xx in time is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours, then marked failed: 8 attempts over about two days. Every retry carries the same CorrConnect-Event-Id and a higher CorrConnect-Delivery-Attempt. Events for one subscription can arrive out of order, so don't rely on order; created_at in the body tells you when it happened.

After 20 failed attempts in a row (attempts, not events), the subscription pauses and nothing more is queued for it. Fix the endpoint, then turn it back on with POST .../enable. Events from while it was paused are not replayed; use GET /v1/activities to catch up.

GET .../deliveries shows the newest 50 deliveries (up to 100 with limit): status, attempts, your last HTTP status and a coded error. It never shows payloads. last_error is one of:

`last_error`Meaning
http_statusYour server answered, but not with 2xx. last_status_code has the status.
delivery_failedNo answer: DNS, connection, TLS or the 10-second deadline. Deliberately coarse.
invalid_destinationThe stored URL is no longer allowed, for example it now resolves to a private address.
subscription_disabledThe subscription was paused before this delivery went out.

Finished deliveries are kept for 30 days.

What you'll receive

  • activity.created goes to apps that hold activities:read in an active grant for that athlete, and only for athletes with a verified email.
  • grant.revoked goes to the app whose access ended. When it does, CorrConnect drops any activity.created deliveries still waiting for that athlete.

Payloads carry IDs only. See the webhook reference for every field and header.