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 seq order, 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.

Unclear? Ask a developer

Read the reply and continue the conversation on this site using the private link you receive after sending. Incoming email is not accepted.