Every review, grade, rate and fee we publish, over HTTP. The catalogue is public and needs no key.
Full endpoint reference · OpenAPI document
All endpoints are relative to https://api.paymentreview.com/v1.
curl https://api.paymentreview.com/v1/reviews?limit=5
Reading the catalogue needs no credentials. A key is only needed for your own records — it authenticates as your account, carries exactly your permissions, and cannot change a review. Generate one at app.paymentreview.com/connect/.
curl https://api.paymentreview.com/v1/edit-suggestions \ -H "Authorization: users API-Key YOUR_KEY"
The users prefix is part of the value, not a typo — it names the collection the key belongs to.
List endpoints take limit (default 10, max 100) and page, and return the documents in docs alongside totalDocs, totalPages, hasNextPage and hasPrevPage.
Both are whole numbers, and both are checked before the query runs: limit outside 1–100, page below 1, or a sort this page does not list comes back as a 400 naming the parameter rather than a page of surprising results.
Filters use bracket syntax. Documented filters are where[slug][equals], where[grade][equals] and where[type][equals]. The server currently accepts more than that; anything not in the reference is unsupported and may change without notice.
Results can also be sorted with sort, one of companyName, -companyName, updatedAt or -updatedAt.
curl "https://api.paymentreview.com/v1/reviews?where[grade][equals]=A%2B&sort=companyName"
Published reviews only. Drafts and unpublished listings are filtered out for every caller, keyed or not.
Failures return the matching HTTP status and a JSON body of the form { "errors": [{ "message": "..." }] }. A missing or invalid key gives 403; an unknown id gives 404; a parameter outside what this page documents gives 400.
If your listing has an endpoint configured under app.paymentreview.com (Listings → your listing → Integrations), a lead submitted through paymentreview.com's public matching form is pushed there as a signed HTTP POST the moment it arrives. There is no setup here beyond that page — this section documents the contract your endpoint receives, how to verify it, and the pull endpoint below.
Not every lead in your inbox goes through this path — an introduction a signed-in merchant requests directly from your listing in the portal is not currently pushed as a webhook, only the ones that come from the public form are. Both kinds land in the same place either way, so treat GET /v1/introductions below as the complete list and the webhook as a low-latency copy of part of it, not the other way around.
One JSON object per lead, sent as the request body. Field names are the contract — treat them as stable.
{
"id": 482,
"createdAt": "2026-08-03T14:12:05.000Z",
"listing": { "slug": "example-processor", "companyName": "Example Processor" },
"contact": {
"name": "Dana Reed",
"businessName": "Reed Botanicals",
"email": "dana@reedbotanicals.example.com",
"phone": "+1 555 0100"
},
"qualification": { "monthlyVolumeBand": "50k_250k", "industry": "CBD" },
"message": "Looking to switch processors before renewal.",
"alsoContacted": 2
}id— the lead's id. Stable across retries; use it to dedupe.listing — which of your listings this lead is for, useful if you run more than one.contact and qualification — what the merchant supplied. Any field inside qualification may be null — a merchant is not required to answer everything.alsoContacted — how many other providers this same merchant reached out to in the same submission. It is a count only, and deliberately never names who they were — one provider's inbox is not another provider's business.Three headers arrive with every delivery:
x-pr-signature — an HMAC-SHA256 hex digest, described below.x-pr-timestamp — milliseconds since the Unix epoch, the same value that is signed. Compare it against Date.now() directly, not a value divided down to seconds.x-pr-delivery— an id for this delivery attempt, distinct from the lead's own id above. It stays the same across every retry of the same lead, so it doubles as a dedupe key.The signed material is `${timestamp}.${body}`, not the raw body alone — a signature over the body by itself would verify forever, so a captured request could be replayed as a fresh lead at any time. Binding the timestamp in means a replay is only valid inside a window you enforce yourself. Reject anything more than five minutes old (or in the future), and compare signatures in constant time rather than with ===.
const crypto = require("node:crypto");
function isValidLeadWebhook(rawBody, headers, secret) {
const timestamp = Number(headers["x-pr-timestamp"]);
const signature = headers["x-pr-signature"];
const FIVE_MINUTES_MS = 5 * 60 * 1000;
if (!Number.isInteger(timestamp) || Math.abs(Date.now() - timestamp) > FIVE_MINUTES_MS) {
return false; // too old, too new, or malformed — reject before touching the signature
}
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
// Check the string length before decoding, not after: a valid signature with
// hex garbage appended decodes to the same bytes as `expected` and would
// otherwise slip past a length check done on the decoded buffers instead.
if (typeof signature !== "string" || signature.length !== expected.length) return false;
const a = Buffer.from(expected, "hex");
const b = Buffer.from(signature, "hex");
return crypto.timingSafeEqual(a, b);
}rawBody has to be the exact bytes received — verify before you parse the JSON, not after.
Your signing secret (something like whsec_EXAMPLE_NOT_A_REAL_SECRET) is shown exactly once, right after you generate or regenerate it in the portal — store it immediately. Regenerating replaces it immediately: anything still verifying against the old secret stops working from that moment, with no overlap.
We attempt delivery immediately when a lead comes in, and retry a failed attempt up to 4 more times — 5 attempts total — at roughly 1 minute, 5 minutes, 30 minutes and 2 hours after the previous one. Retries are picked up on a 10-minute sweep, so those gaps are a floor, not a guarantee.
A 5xx, 408, 429, or no response within 10 seconds is retried — all four say something went wrong on our end, yours, or the network, not that the lead itself was bad. Any other 4xx is treated as your considered answer about that specific lead and is not retried. In practice: if your endpoint validates the payload and might reject it, return a 2xx and queue the problem internally — a 4xx you send back for a bad payload is final on our side, so returning one is how you lose the lead, not how you get a second attempt at it.
After the fifth attempt without a 2xx, delivery is marked failed and we stop. The lead is not lost — it is still in your portal inbox and reachable through GET /v1/introductions below — only the webhook push has given up.
Turning delivery off in the portal does not queue anything to send once you turn it back on. A lead that arrives while delivery is off is never pushed to your endpoint at all — there is no delivery attempt recorded for it. If a lead already has a retry scheduled when you turn delivery off, that retry is abandoned rather than resumed later, and re-enabling does not bring it back. Either way the lead itself is untouched and stays in your portal inbox; only the webhook push is affected. Use the pull endpoint below if you need a guarantee nothing is missed.
GET https://api.paymentreview.com/v1/introductions, authenticated the same way as everything else on this page, returns leads for whichever of your listings you are on — never anyone else's, and not because of a filter you pass; that scoping happens on our side regardless of what where you send.
Two filters worth knowing: where[status][equals] — one of requested, accepted, declined or closed — to pull just the open ones, and where[review][equals] if you manage more than one listing and want a single one at a time.
curl "https://api.paymentreview.com/v1/introductions?where[status][equals]=requested" \ -H "Authorization: users API-Key YOUR_KEY"
Use this as a backstop against any gap in webhook delivery, including the one above: anything that never lands at your endpoint is still here.
None enforced today. Be reasonable, set a descriptive User-Agent, and we will tell you here before that changes.