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:
- Read the raw body as bytes. Don't parse and re-serialize it before checking the signature.
- Verify
CorrConnect-Signatureagainst the raw body. Reject anything that fails with a400. - Drop it if you've already handled this
CorrConnect-Event-Id. - Queue the work and answer
204(any2xxworks) within 10 seconds. That deadline covers the whole request, connect to last byte. - Process the queue: fetch the activity, or delete the athlete's tokens.
2. Verify the signature
The header looks like t=1760011200,. v1 is the lowercase hex HMAC-SHA256 of the timestamp, a full stop and the raw body, keyed with your subscription's secret:
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.
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);
}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)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
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);
}
}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:
# 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"}'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 / 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 / to catch up.
GET .../ 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_status | Your server answered, but not with 2xx. last_status_code has the status. |
delivery_failed | No answer: DNS, connection, TLS or the 10-second deadline. Deliberately coarse. |
invalid_ | The stored URL is no longer allowed, for example it now resolves to a private address. |
subscription_ | The subscription was paused before this delivery went out. |
Finished deliveries are kept for 30 days.
What you'll receive
activity.createdgoes to apps that holdactivities:readin an active grant for that athlete, and only for athletes with a verified email.grant.revokedgoes to the app whose access ended. When it does, CorrConnect drops anyactivity.createddeliveries still waiting for that athlete.
Payloads carry IDs only. See the webhook reference for every field and header.