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.

7 min readUpdated 6 August 2026notifications, email, push, disputes, admin

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:

  • notifyTradeEvent on TRADE_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, as P2P_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.

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.