Events and webhooks
The network reports events with a POST request to the webhook URL you set in the dashboard. No URL? Pull events yourself — see below. Each event is JSON like this:
{
"id": "evt_6f1c2b4e-8a3d-4c55-9b1e-2f0a7d9c1e44",
"type": "match.created",
"created_at": "2026-10-24T12:00:00Z",
"data": { "…": "…" }
}
Headers:
| Header | Value |
|---|---|
X-Network-Signature |
sha256=<hex> — HMAC-SHA256 of the raw body with your environment’s signing secret (webhook_secret) |
X-Network-Event |
The event type, same as type |
Content-Type |
application/json |
All events
| Type | When | What to do |
|---|---|---|
like.received |
A person from another app liked your user (super: true — a super like, message in from.super_like) |
Show it under “Liked you”; a super like first and highlighted |
like.withdrawn |
The person took their like back before a match or is no longer available (reason: deleting their account, hidden after reports, profile rejected by moderation, removed). If they come back, like.received comes again |
Remove it from “Liked you” |
match.created |
A mutual like | Create a conversation, save the match_id |
message.created |
A new message for your user, already translated | Show it, send a push notification; with a warning if suspicious: true |
message.read |
The other person read your user’s messages | Show “read” |
match.closed |
The other person closed the match, blocked your user or was removed from the network (peer_deleted: true) |
Close the conversation, the history stays; with peer_deleted show “Account deleted” / “Left the network” instead of the name and photo |
match.frozen |
the other person is deleting their account (reason: account_deleting, can restore within 30 days) or was hidden by the network after reports (reason: suspended) |
Show “Account deleted” or “Account unavailable”, no messaging |
match.unfrozen |
the other person restored their account or was reinstated after reports | Bring back their name, photo and the chat |
match.erased |
the other person deleted the pair or blocked with erase | Delete the chat on your side |
member.reported |
Someone reported your user | Review it under your own rules |
member.suspended |
The network hid your user after reports pending review; their matches are frozen | Review the reports: bring them back (reinstate) or block them in your app |
member.rejected |
The profile did not pass the network’s moderation: reason, photo when a photo or poster is the problem, video when a clip is |
Ask the user to fix the profile and send it again |
photo_check.updated |
a new result of the network’s photo check — after an attempt, a dispute or the moderator’s decision | Show the person the result; if passed, send a verification claim with check_id |
ping |
You clicked “Send a test event” in the dashboard | Reply 2xx — this checks the URL and the signature |
The fields of each event are in the API reference.
Signature verification
Required: without it, anyone could send you a “message” on behalf of the network. Compute the HMAC over the raw request body — before parsing the JSON — and compare in constant time.
PHP
<?php
$body = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $body, getenv('PEREVAL_WEBHOOK_SECRET'));
if (!hash_equals($expected, $_SERVER['HTTP_X_NETWORK_SIGNATURE'] ?? '')) {
http_response_code(401);
exit;
}
$event = json_decode($body, true);
Python (Flask)
import hashlib, hmac, os
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["PEREVAL_WEBHOOK_SECRET"].encode()
@app.post("/pereval/hook")
def hook():
body = request.get_data() # raw body
expected = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers.get("X-Network-Signature", "")):
abort(401)
event = request.get_json()
handle(event)
return "", 204
Node.js (Express)
import crypto from "node:crypto";
import express from "express";
const app = express();
app.post("/pereval/hook", express.raw({ type: "application/json" }), (req, res) => {
const expected = "sha256=" + crypto.createHmac("sha256", process.env.PEREVAL_WEBHOOK_SECRET).update(req.body).digest("hex");
const got = req.get("X-Network-Signature") ?? "";
if (got.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected))) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
handle(event);
res.sendStatus(204);
});
Go
func perevalHook(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "bad body", http.StatusBadRequest)
return
}
mac := hmac.New(sha256.New, []byte(os.Getenv("PEREVAL_WEBHOOK_SECRET")))
mac.Write(body)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(r.Header.Get("X-Network-Signature"))) {
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
var event struct {
ID string `json:"id"`
Type string `json:"type"`
Data json.RawMessage `json:"data"`
}
_ = json.Unmarshal(body, &event)
handle(event)
w.WriteHeader(http.StatusNoContent)
}
Retries and duplicates
- A 2xx response means the event was delivered. Respond quickly; do long-running work in the background.
- Any other response, or no response within 10 seconds, triggers a retry after 10 s, 1 min, 5 min, 30 min, 2 h, 6 h, then once a day, for up to 3 days in total.
- The same event can arrive twice. Store processed
ids and skip duplicates:
CREATE TABLE pereval_events (id text PRIMARY KEY, received_at timestamptz DEFAULT now());
-- in the handler, in the same transaction as your changes:
INSERT INTO pereval_events (id) VALUES ($1) ON CONFLICT DO NOTHING; -- 0 rows → already processed
- In the 1.0.0 release contract, a recipient’s pending events retain causal
seqorder, including retries. Different recipients progress independently. Expired (7 days) or permanently failed events are not replayed forever: reconcile matches and history against the current network state after an outage. Platform suspension pauses delivery without deleting the outbox or extending normal retention.
Pull events yourself
GET /v1/events?after=<seq>&limit=100
Returns your environment’s events in seq order. It helps in two cases:
- your platform has no webhook URL — a bot, a website on simple hosting. Without a URL, the network doesn’t push events; it only returns them on request;
- you need to recover after an outage.
{
"items": [
{ "seq": 41, "id": "evt_…", "type": "message.created", "created_at": "2026-10-24T12:00:00Z", "data": { "…": "…" } }
],
"next_after": 41
}
Make the first request with after=0 and the next one with after=next_after. Store next_after on your side. data is the same as in the webhook of that type. Up to 500 events per request.
Pulled events count as delivered. Events are kept for 7 days. For live chat, polling every few seconds works well; the overall limit is 600 requests per minute per platform.
If your server is unavailable
Events are queued and delivered after your server recovers, for up to 3 days. They are kept for 7 days: pull whatever didn’t arrive by webhook with GET /v1/events. Restore the rest with GET /v1/members/{id}/matches, GET /v1/members/{id}/likes and GET /v1/matches/{id}/messages.
Boost events
boost.started, boost.extended, boost.paused, boost.resumed and boost.completed use the same signed queue and event-id deduplication. Forward them to the services integration; ignore unknown events without error. Reconcile state with GET /v1/members/{external_id}/boost. These events do not confirm a human impression. Details.
Erasure, retries and old backups
A successful HTTP response does not prove the network has durably recorded ACK: a crash can cause redelivery. Commit the effect and processed event id atomically in your database before responding 2xx. Process-memory deduplication is insufficient.
For match.erased, save a durable erasure marker by match_id atomically with removing the local chat. It must survive restart and ordinary event-log cleanup. Late match.created, message.created or match.unfrozen must not recreate erased chat. A known erasure is an acknowledged no-op (2xx), not endless 503 retries. Reconcile an unknown match before acting; do not rebuild history from a late event alone.
After restoring an old backup, keep chat access closed until current state is reconciled and newer erasures are applied. Seven-day outbox is not a complete deletion journal for a 30-day backup. Net and Via use separate operational journals; this public API does not yet offer a universal deletion-history export for third-party platforms. Platforms need their own durable marker/recovery plan. If continuity cannot be established, keep access closed and contact support.