Forwarding
Forwarding is in beta. Contact JustiFi Customer Success to have it enabled for your platform and to get a destination allow-listed.
Forwarding allows you to forward any card payment method stored with JustiFi to a third party. You supply the body and headers the destination requires and mark where the card details belong; JustiFi substitutes the real values and sends a request to the destination.
Forwarding is asynchronous. Creating a forwarding request returns immediately, before anything leaves JustiFi, and the outcome arrives by webhook or by retrieving the request.
How it works
- You have a card payment method in JustiFi — any card payment method, however it was created, including one tokenized by the Tokenize Payment Method web component or the Checkout web component.
- You
create a forwarding request
naming the payment method, the destination, and the body and headers the destination expects. Card
details are written as
{{card_number}},{{card_expiry_month}},{{card_expiry_year}}and{{cardholder_name}}tags. - JustiFi answers
201with the request inpendingstatus, then sends the request to the destination in the background; it swaps each tag for the real value, encodes the body in the format the destination expects, and relays your headers. - You get the outcome from the
forwarding_request.completedorforwarding_request.failedevent, or by retrieving the forwarding request.
Prerequisites
The destination must be allow-listed by JustiFi. Forwarding will only send to destinations JustiFi has approved, matched exactly as a full URL — no trailing slash, casing or query string normalization is applied. Contact JustiFi Customer Success to have a destination added, and use exactly the URL you are given.
You supply the destination's credentials. JustiFi holds no credentials of its own for the
destination. Any authorization required by the destination needs to be passed to the
forwarding_request.headers and is relayed as-is.
Only card payments can be forwarded. Bank accounts, card-present payment methods and digital wallets (Apple Pay and Google Pay) are rejected.
Creating a forwarding request
Create the request with
Create a Forwarding Request.
forwarding_request.body is the shape the destination expects, not a JustiFi shape — JustiFi passes
it through, substituting only the tags it finds.
curl --request POST \
--url https://api.justifi.ai/v1/forwarding/requests \
--header 'authorization: Bearer YOUR_ACCESS_TOKEN' \
--header 'content-type: application/json' \
--header 'idempotency-key: YOUR_IDEMPOTENCY_KEY' \
--data '{
"payment_method": "pm_123xyz",
"url": "https://api.stripe.com/v1/payment_methods",
"forwarding_request": {
"body": {
"type": "card",
"card": {
"number": "{{card_number}}",
"exp_month": "{{card_expiry_month}}",
"exp_year": "{{card_expiry_year}}"
},
"billing_details": { "name": "{{cardholder_name}}" },
"metadata": { "reference": "ord_9f21" }
},
"headers": {
"Authorization": "Bearer sk_live_destination_key"
}
}
}'
No Sub-Account header is needed — the account is retrieved from the payment method.
You always write forwarding_request.body as a JSON object. JustiFi encodes it into the format the
destination requires — form-encoding it, for instance, when the destination expects form data — and sets
Content-Type to match, so you do not need to send one. If you do send Content-Type, yours is relayed
unchanged and you are responsible for it matching the body JustiFi produces.
The response is the forwarding request in pending status, with response still null:
{
"id": "fwd_123xyz",
"type": "forwarding_request",
"data": {
"id": "fwd_123xyz",
"account_id": "acc_123xyz",
"payment_method_id": "pm_123xyz",
"url": "https://api.stripe.com/v1/payment_methods",
"http_method": "POST",
"provider": "stripe",
"status": "pending",
"failure_reason": null,
"replacements": ["card_number", "card_expiry_month", "card_expiry_year", "cardholder_name"],
"request": {
"body": {
"type": "card",
"card": { "number": "4242", "exp_month": 5, "exp_year": 2042 },
"billing_details": { "name": "Lindsay Whalen" },
"metadata": { "reference": "ord_9f21" }
},
"headers": { "Authorization": "[FILTERED]" }
},
"response": null,
"attempted_at": null,
"created_at": "2024-01-01T12:00:00Z",
"updated_at": "2024-01-01T12:00:00Z"
},
"page_info": null
}
replacements lists the tags JustiFi found and will substitute, which is a useful confirmation that
your tags were read the way you intended.
Card tags
| Tag | Substituted with |
|---|---|
{{card_number}} | the full card number |
{{card_expiry_month}} | the expiration month, as a number (5, not "05") |
{{card_expiry_year}} | the expiration year, as a four digit number (2042) |
{{cardholder_name}} | the cardholder name on the payment method |
Three rules apply:
- A tag must be the entire value of its field.
"number": "{{card_number}}"works;"number": "card-{{card_number}}"is rejected. There is no string interpolation. - Unknown tags are rejected. Only the four tags above are supported.
{{card_cvc}}is not supported. JustiFi does not retain the CVV after the request that collected it. Destinations that require a CVV cannot be reached by forwarding.
Braces that are not a whole-value tag are left alone, so a value like "ref: {{legacy}}" passes through
untouched. A body with no tags at all is accepted, and replacements comes back empty.
Getting the outcome
Forwarding requests move through four statuses:
| Status | Meaning |
|---|---|
pending | accepted and queued; nothing has been sent |
processing | the request is being sent and the outcome is not yet known |
completed | the destination answered — see response.status_code for the outcome |
failed | the destination could not be reached — see failure_reason |
completed means the destination answered, not that it accepted the request. A 402 from the
destination is a completed forwarding request with response.status_code of 402. Always check
response.status_code.
When a request failed, response stays null and failure_reason says why:
| Failure Reason | Meaning |
|---|---|
timeout | the destination did not answer in time |
connection_error | the connection or TLS handshake to the destination failed |
internal_error | JustiFi failed to send the request |
Webhooks
Subscribe to forwarding_request.completed and forwarding_request.failed. The event payload is the
same object the API returns. Exactly one of the two is published per forwarding request. See
Forwarding Request events.
Polling via API
If you prefer polling, you can use the
Get a Forwarding Request
API and wait for the status to change from pending or processing to completed or failed.
curl --request GET \
--url https://api.justifi.ai/v1/forwarding/requests/fwd_123xyz \
--header 'authorization: Bearer YOUR_ACCESS_TOKEN' \
--header 'sub-account: acc_123xyz'
Once the destination has answered, response is populated:
{
"status": "completed",
"failure_reason": null,
"response": {
"id": "fwdr_123xyz",
"status_code": 200,
"body": {
"id": "pm_1QabcStripeExample",
"object": "payment_method",
"card": { "last4": "4242" }
},
"headers": { "content-type": "application/json" },
"response_time_ms": 512
},
"attempted_at": "2024-01-01T12:00:01Z"
}
List Forwarding Requests
returns a sub account's forwarding requests newest first, and can be filtered by payment_method_id,
created_before and created_after.
What JustiFi stores, and what you can read back
Forwarding is designed so that reading a forwarding request back can never expose card data, no matter what you sent or what the destination returned:
- Request body — stored with the tags still in it, never with the card details. When you read it
back, the card number renders as its last four digits; the expiration date and cardholder name render in
the clear. This is true wherever you put a tag, including somewhere unexpected like a
metadatafield. - Request headers — names are kept so you can confirm what was relayed, but every value renders as
[FILTERED]. The destination credentials you send are never readable again, so keep your own copy. - Response body and headers — anything that looks like a card number is reduced to its last four digits before being stored, so a destination that echoes the card back cannot leak it through JustiFi.
Idempotency
Idempotency-Key is required, and works as it does
everywhere else in the JustiFi API.
Retrying with the same key and the same parameters returns the same forwarding request and never sends a
second request to the destination — which matters more here than usual, since the destination may create
a record of its own. Reusing a key with different parameters returns 409.
Limits
- One attempt. A forwarding request that fails is not retried. To try again, create a new forwarding
request with a new
Idempotency-Key. - Timeouts. JustiFi waits up to 10 seconds to connect and up to 90 seconds for the destination to
answer, then records
timeout. - JSON request bodies only.
forwarding_request.bodymust be a JSON object; a string or an array is rejected.forwarding_request.headers, if present, must be a flat object of string values. JustiFi handles encoding the body into the form the destination expects. - Cards only. Bank accounts, card-present payment methods and digital wallet cards cannot be forwarded.
Errors
Every rejection happens before anything is created or sent, so a 400 means nothing reached the
destination.
| Code | Meaning |
|---|---|
forwarding_destination_not_allowed | the url is not on the allow list, or does not match an allow-listed destination exactly |
payment_method_type_not_supported | the payment method is not a card |
digital_wallet_not_supported | the card is an Apple Pay or Google Pay card |
invalid_parameter | a card tag is unknown, or is not the entire value of its field |
forwarding_request_required | forwarding_request is missing |
forwarding_request_body_invalid | forwarding_request.body is not a JSON object |
forwarding_request_headers_invalid | forwarding_request.headers is not an object of string values |
payment_method_not_found | no payment method with that id exists (404) |
forwarding_request_not_found | no forwarding request with that id exists on this sub account (404) |
invalid_id_format | the payment_method id is malformed |
not_authorized | your credentials have no admin access to the account that owns the payment method (403) |