Engineering · 2026-09-11
Feeding a Hard Deny into Your Fraud Rules Engine: Integration Patterns
By J. W. Bouckaert
Two places a deny can sit
A carrier-state deny is a fact about a line: this number changed SIM four hours ago, or it ported to another carrier yesterday. Turning that fact into a refusal means putting it where the system that already says no will look. There are two such places, and picking between them is most of the integration work.
The first is inside the request. Your code calls the lookup at the decision point, reads two fields off the response, and applies its own rule before it answers the user. The deny is synchronous and it belongs to you.
The second is behind a callback. A signal reaches our ingest pipeline, matches a playbook you configured, and a webhook_callback action posts an envelope to an endpoint you run. The deny arrives after the decision it relates to, so your engine applies it to a session in flight or to the next request.
Most stacks end up with both: the inline call on the paths where a person is waiting, and the callback for signals that arrive out of band. They fail in different ways, so wire them separately and ask different things of each.
The inline call
One POST, one key, one number.
POST https://api.passkeybridge.io/v1/shield-carrier-lookup
x-pb-api-key: pb_live_...
Content-Type: application/json
{
"action": "lookup",
"tenant_id": "8f1c2d3e-...",
"phone": "+15551234567",
"provider": "vonage"
}
provider is optional and defaults to vonage; twilio is the other value the endpoint accepts. A success returns the normalised signal, the time the provider leg took, and a note that the result was forwarded onward. The shape below is exactly what the code builds; the values in it are illustrative.
{
"status": "ok",
"signal": {
"provider": "vonage",
"phone_hash": "3f7a9c1e...",
"signal_type": "sim_swap_detected",
"risk_score": 0.8,
"carrier_name": "Example Mobile",
"carrier_type": "mobile",
"ported": false,
"reachable": true,
"roaming": false,
"sim_swap_detected": true,
"sim_swap_age_hours": 4
},
"latency_ms": 412,
"forwarded_to_ingest": true
}
The provider's raw response never reaches you. It is stripped before the reply is assembled, because it carries the number you sent and whatever else the provider chose to echo back.
Read forwarded_to_ingest for what it says. The forward into the ingest pipeline is fire-and-forget with its own ten second deadline, so the field reports that the forward was issued, and a failure downstream of that is logged on our side rather than surfaced in this response. If your engine depends on the event having landed, take the deny from the signal object in front of you.
The rule you write
Write the gate against the booleans and the age, and leave risk_score for the dashboard. The score is a small derived number: 0.60 for a swap, plus 0.20 when the swap is under 24 hours old or 0.10 when it is under 72, plus 0.15 for a port and 0.10 when the line is unreachable, clamped at 1.0. It is useful to plot. A gate written against it inherits a threshold you then have to maintain.
The fields a deny rule actually needs:
| Field | Type | Use in the rule |
|---|---|---|
sim_swap_detected | boolean | The provider reports a swap on this line. |
sim_swap_age_hours | integer or null | Hours since that swap, when the provider gives a time. |
ported | boolean | Vonage reports this directly. On Twilio it is inferred by comparing the current and original network codes, and only when both are present. |
reachable | boolean | On Twilio this is derived from whether a carrier name came back at all. |
signal_type | string | sim_swap_detected when a swap was reported, carrier_check otherwise. |
Two of those rows are the reason to read the provider column rather than treat the schema as uniform. roaming is always false on the Twilio path, because Twilio Lookup v2 does not carry it, so a rule that denies on roaming will never fire for a Twilio tenant. And a null sim_swap_age_hours next to a true sim_swap_detected is a swap of unknown age: decide deliberately whether your window treats that as inside or outside, because the default your code falls into will be silent. Vonage Number Insight Advanced documents what the other path returns.
Which window to pick is a separate argument, and we have made it elsewhere: the freshness piece is about why the age of a signal governs more than its strength.
The 502 is the useful answer
When the provider call fails, the endpoint returns 502 with {"error": "Carrier lookup failed", "detail": "..."} and writes a carrier.lookup_failed audit row. Nothing defaults to clean, and no score is invented to fill the gap.
That is what makes the honest third branch possible. A deny rule has three outcomes rather than two, and the third is ambiguous: your engine already has a path for a signal it could not obtain, and the 502 belongs on it. The pre-filter piece works through what to do with that bin when the goal is cost rather than a deny.
Two properties of the failure are worth designing against. The provider call is capped at fifteen seconds, so the 502 is bounded but slow in the worst case, and an inline gate needs its own shorter deadline if the path you are gating cannot wait that long. And the detail string is the provider's own error message with long digit runs redacted, because some provider errors echo the number that was looked up. Log it; treat it as opaque.
The callback envelope
The other placement hands you a fully formed event. When a playbook's webhook_callback action fires, this is the body, in the shape the code builds it:
{
"event": "sim_swap_detected",
"phone_hash": "3f7a9c1e...",
"subject_ref": "user_8812",
"tenant_id": "8f1c2d3e-...",
"timestamp": "2026-09-11T09:14:02.113Z",
"metadata": { "risk_score": 0.8, "carrier": "example-mobile" }
}
metadata is the ingest payload you sent, with two edits. subject_ref is lifted to the top level so a receiver reads it from one place. And seven keys are removed before the envelope is delivered and before the delivery log is written: phone, phone_number, msisdn, email, ip, ip_address and credential_sha1. Everything else you sent passes through, so a field your own systems need is best added to the ingest payload under a name of your choosing.
Two headers arrive on a first attempt: Content-Type: application/json, and x-pb-signature when your tenant has a callback signing secret configured. A retried delivery also carries the four x-pb-pqc-* headers, because retries are sent by the worker and the worker adds a detached ML-DSA proof; the first attempt is made inside the ingest request, where we keep lattice signing off the hot path. Treat those four as present-or-absent, verify them when they are there, and gate on the HMAC.
Verifying the signature
x-pb-signature is an HMAC-SHA-256 of the exact request body under your callback secret, hex encoded, lowercase.
The rule that catches people: verify against the raw bytes you received, before any JSON parsing. A parse-and-restringify round trip reorders keys or reformats numbers often enough to break verification on traffic nobody tampered with.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody, header, secret) {
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(String(header ?? ""), "hex");
return a.length === b.length && timingSafeEqual(a, b);
}
When no secret is configured the header is absent, and a receiver that reads absent as valid has no authentication at all. Configure the secret first, then reject unsigned posts.
Correlating without an identifier
Nothing in the envelope identifies a person. Two fields do the correlation work instead.
phone_hash is an HMAC-SHA-256 of the number under a server-held pepper, so the same line yields the same digest for you across calls while the digest itself walks back to nothing. Plain SHA-256 would not do this job, for reasons we have written up: a phone number has too little entropy to survive an unkeyed digest.
subject_ref is yours. It is an opaque handle you send on ingest and get back untouched, 1 to 128 printable ASCII characters, and the API refuses it when it looks like an email address or a phone number. Send your own user id rather than an address you happen to have on hand.
Deduplicating without a delivery id
There is no delivery id header on this path. A receiver that needs idempotency builds the key itself, and one property of the envelope is what makes that possible.
timestamp is stamped once, when the envelope is built, and every retry of that delivery carries the same value. So the tuple (tenant_id, event, phone_hash, timestamp) is stable across the retries of one delivery and distinct between two deliveries, at millisecond resolution. Key on that, or put your own unique field in the ingest payload and read it back out of metadata, which is the version that does not depend on our timestamp resolution.
What you cannot rely on is byte identity. A retry is re-serialised from the queue row, so key order may differ from the first attempt even though every value is the same. Verify the HMAC against the bytes of the request in front of you, and build the dedup key from parsed fields.
Four attempts in total, and where they run is what governs a receiver's expectations. The first is made inside the ingest request with a five second deadline of its own, well below the eight second budget every playbook action is raced against. A 5xx, a 429, a network error or that deadline hands the delivery to a queue, and a worker makes up to three more with its own backoff. Any other 4xx is terminal and is never queued, on the reasoning that re-sending a request the receiver called malformed will not improve it.
The practical consequence: a receiver that answers quickly is delivered to on the first attempt, inside the request, in milliseconds. A receiver that is slow or briefly down gets its delivery minutes later, because the worker runs on a five minute cron. That trade was picked deliberately. Putting every delivery on the queue would make all of them minutes late, which is the wrong answer for a deny; leaving the retries inline meant they died with the request, which is the wrong answer for reliability.
So build the receiver to answer fast and work afterwards: accept, enqueue, return 2xx. The delivery log records the attempt number, the response status, the latency, the request body and the first 2,000 characters of your response.
Hard signals and what they trigger
A deny you feed back in has consequences on our side too, and they turn entirely on the event_type string.
Nine types are hard signals: sim_swap, sim_swap_detected, port_out, number_port, number_porting, device_compromise, ss7_intercept, account_takeover and scope_poisoning. A hard signal skips trust scoring. In the same request, before any playbook action runs, every active agent delegate for the tenant is deactivated and every A2A negotiation sitting in an active, negotiated, attested or pending state is revoked, with both counts written to the audit log and onto the event row. The ordering is deliberate: revocation is the decision's immediate consequence, so it does not queue behind whatever third-party call a playbook makes first.
Ten more are soft signals, which go to the trust engine for a graduated response, asynchronously and debounced per tenant and signal type.
Anything else is unknown: stored, matched against playbooks, and given no automatic trust evaluation and no revocation. carrier_check is in that third category, which is what the carrier lookup forwards when it found nothing wrong. A clean lookup is an event and a playbook match by design, and it moves no trust state.
The ingest response is an acknowledgement rather than a verdict. It carries status, result, the count of actions executed and failed, latency_ms and a correlation_id, and it carries no risk verdict and no event id. Take the decision from the inline lookup or from the callback.
Failure modes
| Failure | What you see | Reasonable handling |
|---|---|---|
| Provider down or erroring | 502 from the lookup and a carrier.lookup_failed audit row | Treat as ambiguous and fall through to the engine you already have. |
| Provider slow | The lookup waits up to fifteen seconds | Set a client deadline below the budget of the path you are gating. |
| Line the provider cannot see | sim_swap_detected false with a null age, or a null carrier_name | Ambiguous rather than clean. Decide this case explicitly. |
| Callback receiver slow | The inline attempt hits its five second deadline, and the delivery moves to the retry queue | Accept and enqueue, then return 2xx before doing the work, or the delivery arrives minutes late on a retry. |
| Callback receiver returns 4xx | One attempt, never queued, delivery logged as failed | Check your signature verification first: a 401 from your own verifier looks identical from here. |
| No signing secret configured | No x-pb-signature header at all | Configure one, then reject unsigned posts. |
| Same event delivered twice | Identical body, identical timestamp | Key on the tuple above, or on your own field inside metadata. |
Scope
PasskeyBridge supplies the fact and the plumbing that carries it. The decision stays in your engine: we do not score sessions, hold your rules, or tell you whether a transaction is fraud. A deny is one boolean about one line, delivered either inside your request or to your endpoint, in a shape a rules engine consumes without a new model behind it.
The exact endpoint reference is in the API reference; the callback side, including where to set the signing secret, is in the webhook configuration guide; and provider credentials are in the carrier provider setup guide. Where the deny sits relative to the engines that consume it is on the fraud prevention API page.