API - Webhooks
API - Webhooks
Webhooks let a third-party application receive a notification as soon as a recording (record) or a user (user) changes on the Phyling side, without having to query the API in a loop (polling). This page assumes an OAuth 2.0 application has already been created — see API - Authentication.
1. Principle
A Phyling webhook never sends the data itself. It announces that an object has changed; your application then calls our API back with its OAuth2 access token to read the up-to-date object:
{
"event_id": "evt_3f9e2c1a...",
"event_type": "record.updated",
"event_time": "2026-09-03T10:15:30+00:00",
"subject": { "type": "record", "id": "12345" }
}event_id: stable identifier of the event, to be used for deduplication (see § 5).event_type: see the table below for the full list and what triggers each one.event_time: ISO 8601 timestamp with time zone, at the moment the event was produced (not when it was sent).subject: type and identifier of the object concerned. Forrecord.*, read it again withGET /records/{rec_id}; foruser.*, withGET /users/{id}. On adeleted, the identifier is enough — there is nothing left to read.
No business or personal data travels in the payload. Your application always reads the object again with its own access token, which guarantees a fresh state and limits what a poorly protected webhook URL could reveal.
Available events
event_type | Triggered when | Scope |
|---|---|---|
record.created |
| One event per associated user who has authorized you. |
record.updated |
| Same: one event per associated user who has authorized you. |
record.deleted |
| One event per user concerned. |
user.created | A user is created, or reactivated after having been deactivated. | Applications used by a Manager, a Reseller or an Admin of that user's client. |
user.updated | A user's first name, last name, groups, roles or active/inactive status changes. | Two cumulative scopes: the applications the user has authorized themselves (consent), and those used by a Manager, a Reseller or an Admin of their client. |
user.deleted | A user is deleted, or deactivated. | Applications used by a Manager, a Reseller or an Admin of that user's client. |
[!IMPORTANT] The most common case is the second
record.created. The usual flow is: upload, decoding, then association of the athlete. At decoding time the recording has no user yet, so nothing is sent; it is the association that triggers therecord.created. Do not count on the end of decoding to be notified.
[!NOTE] Neither
record.creatednorrecord.updatedis triggered by a simple internal state change (processing in progress, error, etc.): only by a decoding that succeeds. A never-decoded recording therefore emits neitherrecord.creatednorrecord.updated, even if it is edited; removing a user, however, does emit arecord.deleted.A user deactivation emits both a
user.updated(the active/inactive field changed) and auser.deleted. A reactivation emits auser.updatedand auser.created.
2. Subscribe
You declare your own subscriptions, using your OAuth application credentials (client_id / client_secret, the same as for /oauth/token):
POST /webhooks/subscriptions
Content-Type: application/json
{
"client_id": "app_123",
"client_secret": "secret_456",
"url": "https://mon-serveur.example.com/webhooks/phyling",
"event_types": ["record.created", "record.updated", "record.deleted"],
"payload_format": "phyling",
"replay_history": true
}Response (the secret is shown only once, keep it):
{
"id": 42,
"app_client_id": "app_123",
"url": "https://mon-serveur.example.com/webhooks/phyling",
"event_types": ["record.created", "record.updated", "record.deleted"],
"payload_format": "phyling",
"active": true,
"replay_history": true,
"created_at": "2026-09-03 10:00:00",
"secret": "whsec_9f3a...",
"prev_secret_expires_at": ""
}An application can create several subscriptions (one URL and one list of events per subscription, up to 10 per application), and manage its subscriptions with GET /webhooks/subscriptions, GET|PUT|DELETE /webhooks/subscriptions/{id}. See the Swagger reference for the details of the routes.
[!IMPORTANT] On
GETandDELETE, theAuthorization: Basicheader is mandatory: these methods have no body, and credentials are never read from the query string — putting them there would write them into the access logs.POSTandPUTaccept both forms, header or body.GET /webhooks/subscriptions Authorization: Basic <base64(client_id:client_secret)>When the header and the body both carry a
client_id, the header wins; two differentclient_idvalues give400 invalid_requestrather than a silent choice.
You can also create and manage your subscriptions without writing any code, from the Phyling interface: in the advanced settings, "OAuth Applications" section, by editing your application.
First consent: automatic catch-up
At a user's first consent to your application, Phyling replays their recent history on your subscriptions: one record.created per decoded recording whose session dates from the last 30 days, 100 at most, most recent first.
| When | Only at grant creation. Never on a token renewal, and never again if the user revokes then consents again. |
| Volume | Up to 100 record.created, over a 30-day window of session date. |
| Scope | The subscriptions of your application that listen to record.created, and only those. |
| Pace | The events go through the same sending queue as the others: they arrive spread over several passes, not in a burst. |
| Beyond | Nothing is replayed beyond these limits. The rest of the history is read with GET /records?since=… (§ 6). |
These events are ordinary record.created events, with a fresh event_id: if you already knew these recordings, your deduplication by event_id will not recognize them — treat them as idempotent creations on your side (an "insert if absent" on the recording identifier, not on the event_id).
To disable the automatic catch-up, set replay_history to false — when creating the subscription, or afterwards:
PUT /webhooks/subscriptions/{id}
Content-Type: application/json
{
"client_id": "app_123",
"client_secret": "secret_456",
"replay_history": false
}The setting also exists in the Phyling interface, on the subscription form: "Replay history on first consent".
The subscription keeps receiving all live events: replay_history only governs this automatic catch-up. Changing the field replays nothing and cancels nothing from a replay already queued; it only changes the behavior of future first consents.
What you actually receive
You only receive the events you are entitled to:
record.*: only for users who have authorized your application (OAuth). A user who revokes their access stops the flow concerning them, with no action on your part.user.updated: both regimes at once. You receive it for users who have authorized your application, and for all users of the client if your application is used by a Manager, a Reseller or an Admin of that client — in this second case, without individual consent from the user concerned.user.createdanduser.deleted: reserved for applications used by a Manager, a Reseller or an Admin of a client — a user who has just been created or who disappears obviously cannot have consented themselves.
3. Delivery headers
Each POST carries, in addition to the signature:
| Header | Content |
|---|---|
X-Phyling-Event-Id | The event_id from the body, duplicated, to deduplicate without parsing the JSON. |
X-Phyling-Event-Type | The event_type from the body, to route the request without parsing it. |
X-Phyling-Delivery-Attempt | The attempt number, 1 on the first send. A value greater than 1 indicates a redelivery (§ 5). |
User-Agent | Phyling-Webhooks/1. |
The values in the body remain the reference: the headers are only there for convenience.
4. Verify the signature
Each delivery carries an X-Phyling-Signature header:
X-Phyling-Signature: t=1725360930,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdv1 is HMAC-SHA256(secret, "<t>.<request_body>"), where <request_body> is the exact JSON body received, secret is your subscription secret, and t is the Unix timestamp also present in the header. The timestamp is part of the signature: without it, it would be enough to replay an old request by just changing the displayed timestamp.
Verification in Python:
import hashlib
import hmac
import time
def verify_signature(secret: str, header: str, body: bytes, tolerance_sec: int = 300) -> bool:
timestamp = None
signatures = []
for part in header.split(","):
key, _, value = part.partition("=")
if key == "t":
timestamp = int(value)
elif key == "v1":
signatures.append(value) # can appear twice during a secret rotation
if timestamp is None or abs(time.time() - timestamp) > tolerance_sec:
return False # missing or too old, reject as a possible replay
signed = f"{timestamp}.".encode() + body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, v1) for v1 in signatures)Use hmac.compare_digest (or the constant-time equivalent in your language): a regular == comparison leaks the position of the first differing byte.
Secret rotation
POST /webhooks/subscriptions/{id}/rotate_secretissues a new secret (shown only once) while keeping the old one valid during an overlap window (24 h by default, configurable). During this window, the header carries two v1 signatures:
X-Phyling-Signature: t=1725360930,v1=<old_secret_signature>,v1=<new_secret_signature>It is enough for just one of the two to validate for you to accept the request — which lets you redeploy your verifier with the new secret without missing a delivery during the transition.
Only one old secret is remembered at a time. If you rotate the secret again before having redeployed your verifier with the previous one, the latter becomes invalid immediately — the new overlap window only covers the secret that has just been replaced, not the one before. Wait until you have finished redeploying before rotating the secret again.
5. Duplicates and ordering
Delivery is at-least-once: the same event may arrive more than once (lost network response, retry after failure…). Your application must deduplicate on event_id — for example by keeping a table of already processed event_id values for a few days.
No ordering is guaranteed between two events, including between two events of the same subject. If relative order matters to you, rely on event_time or on the state read via the API, never on the arrival order of the HTTP requests.
Each delivery has a 10-second timeout: respond quickly (2xx) and process the event asynchronously if processing is long.
6. Retry and deactivation
An event that is not accepted (non-2xx status, timeout, connection refused…) is retried with exponential backoff for about 24 hours (8 attempts). A redirect is not followed: a 3xx counts as a failure, your subscription URL must be the final address. After this delay without success, the subscription is deactivated (active: false) and Phyling is alerted: reactivate it (PUT /webhooks/subscriptions/{id} with active: true) once your endpoint is reachable again.
Catching up on what you missed
Webhooks notify a change; they do not replace a resynchronization. The catch-up route is GET /records?since=…: it returns recordings in ascending modification date order and gives you a cursor for the next page. This is what makes a subscription deactivation bearable — you recover everything that happened during the outage.
It is called with the user's OAuth2 access token, like the rest of the API, and therefore only returns what that user has authorized you to read.
GET /records?since=2026-09-01T00:00:00&pageSize=100
Authorization: Bearer <access_token>{
"total": 137,
"next_since": "2026-09-01T14:22:08.512000|8471",
"records": [ { "id": 8402, "…": "…" }, { "id": 8471, "…": "…" } ]
}since accepts two forms, and only two: an ISO 8601 date — naive (2026-09-01T00:00:00) or with an offset (2026-09-01T00:00:00+02:00, Z suffix accepted) — to start a catch-up, or a next_since that we returned to you, to read the next page. Anything else (an epoch in milliseconds, null, an arbitrary string) answers 400 Invalid modifiedSince: <value>. An empty since is not an error: it simply applies no filter, and the response then carries no cursor.
Walking the cursor, and its stop condition:
- First call: pass an ISO 8601 date (
since=2026-09-01T00:00:00), the date from which you want to catch up. - Process the recordings on the page.
- If the response does not contain
next_since, you are done: you are up to date. This is the only stop condition. - Otherwise, call the route again with
since=<next_since>and resume at step 2.
since = "2026-09-01T00:00:00" # first catch-up, then the stored cursor
while True:
answer = get("/records", params={"since": since, "pageSize": 100}).json()
for record in answer["records"]:
handle(record)
if "next_since" not in answer:
break # caught up: no cursor means no page left
since = answer["next_since"]
store(since) # so the next catch-up resumes here, not from a dateSeven points that avoid an infinite loop or a full replay:
next_sinceis an opaque cursor. Do not interpret it as a date, do not truncate it, do not reformat it: send it back verbatim. Its form may change without notice, and a received cursor is always accepted as is.pageIdis incompatible withsinceand answers400: the cursor is the pagination, apageIdon top of it would skip a whole page — on a catch-up route, that would be an invisible data loss. Set the page size withpageSize, which remains free, and only advance the cursor.- A simple
last_modified >= datefilter paginated bypageIdis not enough — this is the very first version of this route, which did not terminate cleanly: a recording modified while you are catching up can slip in before your current position and get skipped, or come back in a page already read. The(last_modified, id)cursor does not have this problem, whatever changes in the database while you paginate: each page resumes exactly where the previous one stopped, never at a position. - Persist
next_sincebetween two catch-ups. It is what avoids re-reading the entire history on every pass: without it, a periodic poll would start again from a date and replay everything. next_sinceis absent when the page is empty, and it alone says that it is finished. Two false stop conditions not to use: "next_sinceidentical to the previous one" (can no longer happen) and "fewer thanpageSizeitems" (false when the total is an exact multiple ofpageSize).totalis not a stable total: it counts the remaining recordings after the cursor, so it decreases with each page and equals0on the last one. It is a useful gauge of how far behind you are, never a stop condition.- A cursor advances strictly: the last row of a page never comes back at the top of the next one. Only the first call, made with a bare date, includes recordings modified exactly at that date — and there may be several of them, not just one. You may therefore see those once again. As with webhooks, handle a re-read of a recording idempotently, on the recording identifier.
A since value that is neither an ISO 8601 date nor a cursor received from us answers 400.
[!TIP]
POST /records/allaccepts the same mechanism under the namemodifiedSince— it is the same route, one more argument alongside your usual filters (userIds,clientIds,sportIds…), not a separate mode. Useful for catching up on one specific user only if your integration acts with a role that sees several without a filter (Manager, Coach, Reseller see all users in their scope by default). A webhook, for its part, runs with an OAuth2 token scoped to a single consenting user: this filter is already implicit there, adding it changes nothing.
Keep this catch-up periodic even when everything is going well: it covers a subscription deactivation just as well as a temporary outage of your endpoint.
Related files
- API - Authentication — create the OAuth2 application that carries your subscriptions.
- Swagger reference — full details of the
/webhooks/subscriptionsroutes.