Which P2P events notify whom, and the admin dispute blast
Every notification P2P sends — recipients, channels and priority per event — plus the dispute fan-out that emails and pushes to every Admin and Super Admin on your install.
P2P sends notifications from four functions, all in
(ext)/p2p/utils/notifications.ts: trade events, an admin fan-out, offer
events and reputation events. This page is the inventory, because two of the
paths reach your staff's inboxes rather than your customers', and one of
them fires on every dispute with no way to narrow who receives it.
There is no per-admin subscription, no P2P-permission filter and no volume cap. On a board with a busy dispute queue and a team of a dozen admins, that is a dozen emails and a dozen pushes per case. Read the admin blast before you grow the team.
Trade events
notifyTradeEvent(tradeId, event, data) looks the trade up with both parties,
picks recipients per event, and sends through the platform notification service.
Every one links to /p2p/trade/{id} and carries the idempotency key
p2p-<event>-<tradeId>-<userId>, which de-duplicates for 24 hours.
| Event | Who is told | Channels | Priority |
|---|---|---|---|
TRADE_INITIATED |
the party who did not start it | IN_APP, EMAIL, PUSH | NORMAL |
PAYMENT_CONFIRMED |
the seller | IN_APP, EMAIL, PUSH | HIGH |
TRADE_COMPLETED |
both parties | IN_APP, EMAIL, PUSH | NORMAL |
TRADE_DISPUTED |
both parties — and every admin | IN_APP, EMAIL, PUSH | URGENT |
TRADE_CANCELLED |
the party who did not cancel | IN_APP, EMAIL, PUSH | NORMAL |
TRADE_EXPIRED |
both parties | IN_APP, EMAIL, PUSH | URGENT |
ADMIN_MESSAGE |
both parties | IN_APP, PUSH | NORMAL |
TRADE_MESSAGE |
the other party | IN_APP only | NORMAL |
ESCROW_RELEASED |
the buyer | IN_APP, EMAIL, PUSH | HIGH |
NEW_MESSAGE |
the other party | IN_APP only | NORMAL |
Three of these need a note.
Chat messages are in-app only, deliberately. TRADE_MESSAGE and
NEW_MESSAGE are the only two events excluded from push, and their recipient is
built with email off. A trade room is a live conversation; emailing every line
of it would be unusable. If a trader complains they "get no notification when
the other side writes", this is why — the badge is the notification.
ADMIN_MESSAGE does not send email, despite being built with email
requested. The channel selector only adds EMAIL for events on its urgent or
important lists, and ADMIN_MESSAGE is on neither. So an admin broadcast into a
trade room reaches both parties in-app and by push, and nothing lands in their
inbox. Worth knowing when you are using a broadcast to unstick a case: it will
not wake somebody who is not in the app.
ESCROW_RELEASED and NEW_MESSAGE are defined but never emitted. No route
in the tree calls notifyTradeEvent with either. A successful release sends
TRADE_COMPLETED instead, which reaches both parties. The rows are documented
here so nobody spends an afternoon looking for the release notification that
does not exist.
Who gets told when an admin ends a trade
Three admin doors end a trade, and they do not behave the same way.
| Door | Outcome | What is sent |
|---|---|---|
/admin/p2p/dispute/{id} |
trade ends COMPLETED |
TRADE_COMPLETED — both parties, email |
/admin/p2p/dispute/{id} |
trade ends CANCELLED |
a purpose-built "Dispute Resolved" batch — both parties, IN_APP + EMAIL + PUSH, worded as a platform ruling |
/admin/p2p/trade/{id} resolve |
BUYER_WINS / SPLIT |
TRADE_COMPLETED — both parties, email |
/admin/p2p/trade/{id} resolve |
SELLER_WINS / CANCELLED |
TRADE_CANCELLED — see below |
/admin/p2p/trade/{id} cancel |
CANCELLED |
TRADE_CANCELLED — see below |
The dispute door is the one that gets this right: an admin ruling is announced to both parties by the platform, in language that says support decided it, rather than borrowing the ordinary cancellation copy.
TRADE_CANCELLED picks its recipient from data.cancelledBy: whoever cancelled
is excluded and the other side is told "cancelled by <first name>".
The trade-case resolve and cancel doors do not pass a trader as cancelledBy —
resolve passes none at all, cancel passes the admin's own id — and neither
matches the buyer, so the branch falls through to "the seller cancelled". The
result is that a trade you cancel or resolve in the seller's favour notifies
only the buyer, and the message attributes the cancellation to the seller by
name.
Nothing about the money is affected. But if you need both parties to know what you decided, rule on the dispute rather than on the trade, or post an admin broadcast into the trade room as well.
The admin blast
notifyAdmins(event, data) is the only path in P2P that writes to your staff.
It is called from exactly two places today:
notifyTradeEventonTRADE_DISPUTED, so every dispute filed — by a trader or by the 24-hour auto-dispute cron — fires one.createP2PAuditLog, when a logged event resolves to HIGH or CRITICAL risk, asP2P_SECURITY_ALERT.
Who receives it
Every user whose role is named Admin or Super Admin. That is a query on
the role name, not on a P2P permission — an admin with none of the
view.p2p.* permissions still receives every P2P dispute alert, and an operator
you granted view.p2p.dispute to under a custom role name receives none.
Channels are IN_APP, EMAIL and PUSH, at HIGH priority. No idempotency
key is supplied, so each fan-out is a fresh batch and repeat events on the same
trade notify again.
The fan-out is bounded to 5 recipients at a time (NOTIFICATION_BATCH_CONCURRENCY)
because an unbounded batch once exhausted the database pool and stalled the
whole backend. It still reaches everybody — it just takes several passes.
What you can and cannot control
There is no setting that narrows the recipient list, and no P2P screen that manages it. The only opt-out is the recipient's own notification preferences, and it is blunt:
- Turning off the EMAIL or PUSH channel removes it from every notification that admin receives, not just P2P.
- Turning off the ALERT type removes P2P security alerts and every other alert from any product.
- IN_APP cannot be switched off. The preference filter always allows it.
If the volume is a problem, the practical lever is the role name: an admin whose
role is called something other than Admin or Super Admin is not in the
query. Weigh that against everything else that keys off those two names before
renaming anything.
The links these notifications carry
| Event | Link |
|---|---|
TRADE_DISPUTED |
/admin/p2p/trade/{tradeId} |
P2P_SECURITY_ALERT |
/admin/p2p/{entityType}/{entityId}, lower-cased |
HIGH_VALUE_TRADE |
/admin/p2p/trade/{tradeId} |
SUSPICIOUS_ACTIVITY |
a p2p/activity-log path — dead, see below |
| anything else | /admin/p2p |
The SUSPICIOUS_ACTIVITY link is built from p2p/activity-log, which is not
a route. The screen is /admin/p2p/activity. If you ever wire something to
that event, the notification will 404 — edit the URL by hand, or go straight to
the activity log from the P2P dashboard.
P2P_SECURITY_ALERT builds its link from the audit entry's entityType, which
may be TRADE, OFFER, DISPUTE, USER or WALLET. Only the first three
have admin screens; /admin/p2p/user/… and /admin/p2p/wallet/… do not exist.
Neither HIGH_VALUE_TRADE nor SUSPICIOUS_ACTIVITY is emitted by anything in
the tree today, so only the P2P_SECURITY_ALERT case is live.
What actually triggers a security alert
createP2PAuditLog classifies the event, and any entry that resolves to HIGH or
CRITICAL sends a P2P_SECURITY_ALERT to every admin. The default classification
is:
| Risk | Events |
|---|---|
| CRITICAL | FUNDS_TRANSFERRED, UNAUTHORIZED_ACCESS, ADMIN_USER_BANNED |
| HIGH | TRADE_DISPUTED, SUSPICIOUS_ACTIVITY, ADMIN_TRADE_RESOLVED, ADMIN_DISPUTE_RESOLVED |
| MEDIUM | any entry whose metadata.amount exceeds 1000, plus TRADE_CANCELLED, OFFER_DELETED, RATE_LIMIT_EXCEEDED |
| LOW | everything else, including TRADE_2FA_VERIFIED |
A caller may pass an explicit riskLevel, and that wins over the table. This
matters in both directions:
- Release passes LOW explicitly on all three of its audit entries, including
FUNDS_TRANSFERRED. A successful release is a routine business event; letting it take its CRITICAL default would have paged every admin on every completed trade. - Trade initiation passes HIGH. One of the two entries it writes is stamped
HIGH unconditionally, and the second is HIGH whenever the trade amount exceeds
1000. So opening a trade currently fires a security alert to every admin,
and its link points at
/admin/p2p/trade/<offerId>— the offer's id in the trade slot, which will not resolve.
If your admins are drowning in "P2P Security Alert - HIGH" mail, that is where it comes from, and the lever is the recipients' own notification preferences.
Offer events
notifyOfferEvent(offerId, event, data) notifies the offer's owner only.
Base channels are IN_APP and PUSH at NORMAL priority; three events add
EMAIL. The idempotency key is p2p-offer-<event>-<offerId> — no user id and no
timestamp — so a repeat of the same event on the same offer within 24 hours is
suppressed.
| Event | Handled copy | Adds email | Priority | Emitted by |
|---|---|---|---|---|
OFFER_APPROVED |
yes | yes | NORMAL | admin approve |
OFFER_REJECTED |
yes, carries your reason | yes | HIGH | admin reject |
OFFER_EXPIRED |
yes | no | NORMAL | the expiry cron |
OFFER_LOW_BALANCE |
yes | no | HIGH | nothing |
OFFER_TRADE_INITIATED |
yes | yes | NORMAL | nothing |
OFFER_ACTIVATED |
no — generic | no | NORMAL | admin activate |
OFFER_PAUSED |
no — generic | no | NORMAL | admin pause |
OFFER_DISABLED |
no — generic | no | NORMAL | admin disable |
OFFER_FLAGGED |
no — generic | no | NORMAL | admin flag |
Activate, pause, disable and flag all call notifyOfferEvent, but none of those
four event names has a case in the message switch, so all four fall through to
the default: title "Offer Update", body "Your P2P offer has been
updated." — in-app and push, no email, and no reason attached, even though
you were asked for one on disable and flag.
Approve and reject are the two that produce real copy. If you disable an offer and the owner needs to know why, tell them another way; the notification will not.
OFFER_LOW_BALANCE and OFFER_TRADE_INITIATED have full copy but no caller in
the tree. There is no "your offer is running low" warning today.
Reputation events
notifyReputationEvent(userId, event, data) has seven cases. Exactly one is
emitted: MILESTONE_REACHED, from the hourly reputation job, when a trader
first crosses 10, 50 or 100 completed trades. It sends IN_APP, PUSH and EMAIL at
NORMAL priority, with an idempotency key that includes Date.now() — so it is
never de-duplicated, and the job's own "have I already logged this milestone?"
check is the only thing preventing repeats.
MILESTONE_REACHED is the event name the switch handles. REPUTATION_MILESTONE
does not — it is the log row type the job writes, and passing it as the event
name falls through to the generic "Your P2P reputation has been updated."
Anything you wire up yourself must use MILESTONE_REACHED.
The other six cases — REPUTATION_INCREASED, REPUTATION_DECREASED,
POSITIVE_REVIEW, NEGATIVE_REVIEW, TRUSTED_STATUS and the default — are
defined and unreachable. Note also that these notifications link to
/p2p/profile and /p2p/reviews, neither of which is a route in this build.
Failures are swallowed
Every one of these functions wraps its work in a try/catch that logs under
P2P_NOTIF and returns. Callers additionally invoke them without awaiting, or
with a trailing .catch().
That is deliberate and it is the right trade: a missing notification never rolls back the money movement that caused it. A settled escrow stays settled even if the mail server is down.
The consequence for you is that a notification failure is invisible outside the
log. When a trader insists they were never told, grep the backend log for the
P2P_NOTIF module — it is not in the tag table on
Troubleshooting, but it is
the tag every one of these functions writes under. Three shapes are worth
recognising:
| Log line | Means |
|---|---|
Trade <id> not found for notification |
the trade row was gone by the time the notifier ran |
Failed to create notification for user <id> |
one recipient failed; the others still went |
No admin users found for P2P notifications |
no user holds a role named Admin or Super Admin — the dispute blast reached nobody |
The last one is a warning, not an error, and it is the one that matters: it means disputes are being filed and nobody is being told.
Related
- Resolving a dispute — the queue the blast points at.
- Moderating offers and trades — the actions behind the offer events.
- The activity log, and proving who moved an escrow — where the same events are recorded rather than delivered.