Fintechs, ISOs and sponsor banks on the Coris platform
Load your merchant portfolio into Coris by file or API. Coris monitors it, sends alerts by webhook, and your team works them in the portal.
Send your merchant portfolio to Coris by file or API. Coris monitors it and sends alerts by webhook, for your team to work in the portal. This path suits fintechs, ISOs, PayFacs and sponsor banks.
- Step 1POST /
file_uploads/ - Step 2PUT /
file_uploads/ {file_upload_id}/
- FeedSFTP feed
- Step 1POST /
accounts/ - Step 2POST /
accounts/ {account_id}/ persons/ - Onboard (optional)POST /
merchant_profiler/
- Webhookalert.
created - PortalMerchant Alerts
Choose how you send data
You can mix both if your merchant IDs match.
| Compare | Files | API |
|---|---|---|
| Best for | Whole portfolios, daily batches | One merchant at a time |
| You send | Merchant, payment and chargeback records | Merchant details and reference_id |
| You receive | Portfolio, alerts and disputes in the portal | An account linked to a business |
Setup checklist
Start with your key and rules.
- Create a sandbox key in the portal.
- Agree with Coris on monitoring rules, file formats, alert reviewers and CorShield.
- API: map your merchant IDs to
reference_id. - Register a webhook endpoint.
Endpoints you use
| Job | Endpoint |
|---|---|
| Send a file | Create a file upload, Upload file content |
| Add a merchant | Create an account, Add a person to an account, Update an account |
| Receive results | Create a webhook endpoint |
| Optional: add data | Update account websites, Create an account tag |
| Optional: run checks | Merchant Profiler, CorShield |
Set up your key
Set your key and base URL once.
export CORIS_BASE_URL="https://sandbox-api.coris.ai/api/v0_2"
export CORIS_API_KEY="YOUR_API_KEY"import requests
BASE_URL = "https://sandbox-api.coris.ai/api/v0_2"
HEADERS = {"Authorization": "Token YOUR_API_KEY"}const BASE_URL = "https://sandbox-api.coris.ai/api/v0_2";
const HEADERS = {
Authorization: "Token YOUR_API_KEY",
"Content-Type": "application/json",
};The samples below wait with the polling helper. Expand it, and run it too.
Polling helper
# Polls an async endpoint and prints the body once it returns 200.
# 206, 425, 429 and 500 all mean wait and poll again. A 500 re-queues the job.
poll() {
delay=5
for _ in $(seq 1 30); do
http_status=$(curl -s -o /tmp/coris_body -w '%{http_code}' \
-H "Authorization: Token $CORIS_API_KEY" \
"$CORIS_BASE_URL$1?request_id=$2")
if [ "$http_status" = "200" ]; then cat /tmp/coris_body; return 0; fi
case "$http_status" in
206|425|429|500) ;;
*) echo "HTTP $http_status: $(cat /tmp/coris_body)" >&2; return 1 ;;
esac
sleep "$delay"
delay=$((delay * 3 / 2)); [ "$delay" -gt 30 ] && delay=30
done
echo "Timed out" >&2; return 1
}import time
# 206, 425, 429 and 500 all mean wait and poll again. A 500 re-queues the job.
RETRYABLE = (206, 425, 429, 500)
def poll(path, request_id, attempts=30):
"""Polls an async endpoint and returns the JSON body once it returns 200."""
delay = 5
for _ in range(attempts):
response = requests.get(
f"{BASE_URL}{path}", params={"request_id": request_id}, headers=HEADERS
)
if response.status_code == 200:
return response.json()
if response.status_code not in RETRYABLE:
response.raise_for_status()
time.sleep(delay)
delay = min(delay * 1.5, 30)
raise TimeoutError(f"{path} did not finish")// 206, 425, 429 and 500 all mean wait and poll again. A 500 re-queues the job.
const RETRYABLE = [206, 425, 429, 500];
// Polls an async endpoint and returns the JSON body once it returns 200.
async function poll(path, requestId, attempts = 30) {
let delay = 5000;
for (let i = 0; i < attempts; i++) {
const url = `${BASE_URL}${path}?request_id=${requestId}`;
const response = await fetch(url, { headers: HEADERS });
if (response.status === 200) return response.json();
if (!RETRYABLE.includes(response.status)) {
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
}
await new Promise((resolve) => setTimeout(resolve, delay));
delay = Math.min(delay * 1.5, 30000);
}
throw new Error(`${path} did not finish`);
}Send data by file
Upload through the API, or use an SFTP feed.
Step 1: Register the file
Use the file_type Coris gave you when it set up your file layout. Request a file setup if you have none. File formats lists the files Coris loads.
printf 'merchant_id,legal_name,city,state\nM-1001,Acme Plumbing Co.,San Jose,CA\n' > merchants.csv
FILE_UPLOAD_ID=$(curl -s -X POST "$CORIS_BASE_URL/file_uploads/" \
-H "Authorization: Token $CORIS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "file_name": "merchants.csv", "file_type": "YOUR_FILE_TYPE" }' | jq -r '.id')with open("merchants.csv", "w") as f:
f.write("merchant_id,legal_name,city,state\nM-1001,Acme Plumbing Co.,San Jose,CA\n")
response = requests.post(
f"{BASE_URL}/file_uploads/",
headers=HEADERS,
json={"file_name": "merchants.csv", "file_type": "YOUR_FILE_TYPE"},
)
response.raise_for_status()
file_upload_id = response.json()["id"]import { writeFile } from "node:fs/promises";
const csv = "merchant_id,legal_name,city,state\nM-1001,Acme Plumbing Co.,San Jose,CA\n";
await writeFile("merchants.csv", csv);
const registered = await fetch(`${BASE_URL}/file_uploads/`, {
method: "POST",
headers: HEADERS,
body: JSON.stringify({ file_name: "merchants.csv", file_type: "YOUR_FILE_TYPE" }),
});
if (!registered.ok) throw new Error(await registered.text());
const fileUploadId = (await registered.json()).id;Step 2: Upload the file
Each file in a .zip loads separately. To resend a file, create a new upload.
curl -s -X PUT "$CORIS_BASE_URL/file_uploads/$FILE_UPLOAD_ID/" \
-H "Authorization: Token $CORIS_API_KEY" \
-F "[email protected]" | jqwith open("merchants.csv", "rb") as f:
response = requests.put(
f"{BASE_URL}/file_uploads/{file_upload_id}/",
headers={"Authorization": HEADERS["Authorization"]},
files={"file": f},
)
print(response.status_code, response.json())const form = new FormData();
form.append("file", new Blob([csv], { type: "text/csv" }), "merchants.csv");
const uploaded = await fetch(`${BASE_URL}/file_uploads/${fileUploadId}/`, {
method: "PUT",
headers: { Authorization: HEADERS.Authorization },
body: form,
});
console.log(uploaded.status, await uploaded.json());Step 3: Confirm the load
Check the merchant count and totals in the portal too. completed means Coris stored the file, even if a wrong file_type or layout loads no rows.
curl -s "$CORIS_BASE_URL/file_uploads/$FILE_UPLOAD_ID/" \
-H "Authorization: Token $CORIS_API_KEY" | jqresponse = requests.get(f"{BASE_URL}/file_uploads/{file_upload_id}/", headers=HEADERS)
print(response.status_code, response.json())const upload = await fetch(`${BASE_URL}/file_uploads/${fileUploadId}/`, { headers: HEADERS });
console.log(upload.status, await upload.json());Use an SFTP feed
Coris can also set up a scheduled SFTP feed. Ask for one.
Program and ISO data
Merchant files carry the boarding ISO, for filtering the portfolio by ISO. Every file needs a merchant ID. File formats lists what each file carries.
Send data by API
Create each merchant as an account.
Step 1: Create the account
Coris matches the business and returns a request_id. Your onboarding monitoring then runs, if you have rules set up.
REFERENCE_ID="acme-plumbing-$(date +%s)"
REQUEST_ID=$(curl -s -X POST "$CORIS_BASE_URL/accounts/" \
-H "Authorization: Token $CORIS_API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"reference_id\": \"$REFERENCE_ID\",
\"business_name\": \"Acme Plumbing Co.\",
\"address\": { \"address_type\": \"Registration\", \"city\": \"San Jose\", \"state\": \"CA\", \"country\": \"US\" }
}" | jq -r '.request_id')
ACCOUNT_ID=$(poll /accounts/ "$REQUEST_ID" | jq -r '.id')reference_id = f"acme-plumbing-{int(time.time())}"
response = requests.post(
f"{BASE_URL}/accounts/",
headers=HEADERS,
json={
"reference_id": reference_id,
"business_name": "Acme Plumbing Co.",
"address": {
"address_type": "Registration",
"city": "San Jose",
"state": "CA",
"country": "US",
},
},
)
response.raise_for_status()
account_id = poll("/accounts/", response.json()["request_id"])["id"]const referenceId = `acme-plumbing-${Date.now()}`;
const accountJob = await fetch(`${BASE_URL}/accounts/`, {
method: "POST",
headers: HEADERS,
body: JSON.stringify({
reference_id: referenceId,
business_name: "Acme Plumbing Co.",
address: { address_type: "Registration", city: "San Jose", state: "CA", country: "US" },
}),
});
if (!accountJob.ok) throw new Error(await accountJob.text());
const accountRequestId = (await accountJob.json()).request_id;
const accountId = (await poll("/accounts/", accountRequestId)).id;Step 2: Add the owner
Send one call per owner or controller.
curl -s -X POST "$CORIS_BASE_URL/accounts/$ACCOUNT_ID/persons/" \
-H "Authorization: Token $CORIS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"first_name": "Jordan",
"last_name": "Sample",
"email": "[email protected]",
"relationship": { "title": "Owner", "owner": true, "percent_ownership": 100 }
}' | jqresponse = requests.post(
f"{BASE_URL}/accounts/{account_id}/persons/",
headers=HEADERS,
json={
"first_name": "Jordan",
"last_name": "Sample",
"email": "[email protected]",
"relationship": {"title": "Owner", "owner": True, "percent_ownership": 100},
},
)
print(response.status_code, response.json())const person = await fetch(`${BASE_URL}/accounts/${accountId}/persons/`, {
method: "POST",
headers: HEADERS,
body: JSON.stringify({
first_name: "Jordan",
last_name: "Sample",
email: "[email protected]",
relationship: { title: "Owner", owner: true, percent_ownership: 100 },
}),
});
console.log(person.status, await person.json());Step 3: Record the ISO and sponsor bank
Set boarded_iso and sponsor_bank here. Create an account does not store sponsor_bank, and the sandbox drops boarded_iso on create too.
curl -s -X POST "$CORIS_BASE_URL/accounts/$ACCOUNT_ID/" \
-H "Authorization: Token $CORIS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "boarded_iso": "Acme Payments", "sponsor_bank": "Acme Bank" }' | jq '{id, boarded_iso}'response = requests.post(
f"{BASE_URL}/accounts/{account_id}/",
headers=HEADERS,
json={"boarded_iso": "Acme Payments", "sponsor_bank": "Acme Bank"},
)
print(response.status_code, response.json())const updated = await fetch(`${BASE_URL}/accounts/${accountId}/`, {
method: "POST",
headers: HEADERS,
body: JSON.stringify({ boarded_iso: "Acme Payments", sponsor_bank: "Acme Bank" }),
});
console.log(updated.status, await updated.json());Step 4 (optional): Push a transaction
In the response, payer.method holds the card_type you sent, and payer.details.brand holds the payment type. Coris monitors pushed transactions only once that is turned on for you. Request it.
curl -s -X POST "$CORIS_BASE_URL/accounts/$ACCOUNT_ID/transactions/" \
-H "Authorization: Token $CORIS_API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"transaction_id\": \"txn-$(date +%s)\",
\"transaction_date\": $(date +%s),
\"amount\": 12500,
\"currency\": \"USD\",
\"status\": \"Completed\",
\"payer\": {
\"payment_method\": \"card\",
\"payment_method_details\": { \"card_type\": \"visa\", \"card_last_four\": \"4242\" }
}
}" | jqnow = int(time.time())
response = requests.post(
f"{BASE_URL}/accounts/{account_id}/transactions/",
headers=HEADERS,
json={
"transaction_id": f"txn-{now}",
"transaction_date": now,
"amount": 12500,
"currency": "USD",
"status": "Completed",
"payer": {
"payment_method": "card",
"payment_method_details": {"card_type": "visa", "card_last_four": "4242"},
},
},
)
print(response.status_code, response.json())const now = Math.floor(Date.now() / 1000);
const transaction = await fetch(`${BASE_URL}/accounts/${accountId}/transactions/`, {
method: "POST",
headers: HEADERS,
body: JSON.stringify({
transaction_id: `txn-${now}`,
transaction_date: now,
amount: 12500,
currency: "USD",
status: "Completed",
payer: {
payment_method: "card",
payment_method_details: { card_type: "visa", card_last_four: "4242" },
},
}),
});
console.log(transaction.status, await transaction.json());Receive alerts by webhook
Subscribe to events instead of polling List accounts, then read the one account an event names.
WEBHOOK_URL="https://hooks.acmeplatform.example/coris"
SUBSCRIBED='["account.tag.created", "alert.created"]'
# Reuse the endpoint if this URL is already registered. A second POST returns 500.
WEBHOOK=$(curl -s "$CORIS_BASE_URL/webhooks/?disabled=true&limit=100" \
-H "Authorization: Token $CORIS_API_KEY" \
| jq -c --arg url "$WEBHOOK_URL" '.[] | select(.url == $url)')
if [ -z "$WEBHOOK" ]; then
WEBHOOK=$(curl -s -X POST "$CORIS_BASE_URL/webhooks/" \
-H "Authorization: Token $CORIS_API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"Risk events\",
\"description\": \"Sends risk events to the review service\",
\"url\": \"$WEBHOOK_URL\",
\"subscribed_events\": $SUBSCRIBED
}")
fi
WEBHOOK_ID=$(printf '%s' "$WEBHOOK" | jq -r '.id')
SIGNING_SECRET=$(printf '%s' "$WEBHOOK" | jq -r '.signing_secret')
# A reused endpoint may be disabled, or subscribed to other events.
if [ "$(printf '%s' "$WEBHOOK" | jq '.enabled')" = "false" ]; then
curl -s -X POST "$CORIS_BASE_URL/webhooks/$WEBHOOK_ID/enable/" \
-H "Authorization: Token $CORIS_API_KEY" > /dev/null
fi
if [ "$(printf '%s' "$WEBHOOK" | jq --argjson want "$SUBSCRIBED" '(.subscribed_events | sort) == ($want | sort)')" = "false" ]; then
printf '%s' "$WEBHOOK" | jq --argjson want "$SUBSCRIBED" '{name, description, subscribed_events: $want}' \
| curl -s -X POST "$CORIS_BASE_URL/webhooks/$WEBHOOK_ID/" \
-H "Authorization: Token $CORIS_API_KEY" \
-H "Content-Type: application/json" \
-d @- > /dev/null
fiwebhook_url = "https://hooks.acmeplatform.example/coris"
subscribed = ["account.tag.created", "alert.created"]
# Reuse the endpoint if this URL is already registered. A second POST returns 500.
existing = requests.get(
f"{BASE_URL}/webhooks/", params={"disabled": "true", "limit": 100}, headers=HEADERS
).json()
webhook = next((item for item in existing if item["url"] == webhook_url), None)
if webhook is None:
response = requests.post(
f"{BASE_URL}/webhooks/",
headers=HEADERS,
json={
"name": "Risk events",
"description": "Sends risk events to the review service",
"url": webhook_url,
"subscribed_events": subscribed,
},
)
response.raise_for_status()
webhook = response.json()
webhook_id = webhook["id"]
signing_secret = webhook["signing_secret"]
# A reused endpoint may be disabled, or subscribed to other events.
if not webhook["enabled"]:
requests.post(f"{BASE_URL}/webhooks/{webhook_id}/enable/", headers=HEADERS).raise_for_status()
if sorted(webhook["subscribed_events"]) != sorted(subscribed):
requests.post(
f"{BASE_URL}/webhooks/{webhook_id}/",
headers=HEADERS,
json={
"name": webhook["name"],
"description": webhook["description"],
"subscribed_events": subscribed,
},
).raise_for_status()const webhookUrl = "https://hooks.acmeplatform.example/coris";
const subscribed = ["account.tag.created", "alert.created"];
// Reuse the endpoint if this URL is already registered. A second POST returns 500.
const listed = await fetch(`${BASE_URL}/webhooks/?disabled=true&limit=100`, { headers: HEADERS });
let webhook = (await listed.json()).find((item) => item.url === webhookUrl);
if (!webhook) {
const createdWebhook = await fetch(`${BASE_URL}/webhooks/`, {
method: "POST",
headers: HEADERS,
body: JSON.stringify({
name: "Risk events",
description: "Sends risk events to the review service",
url: webhookUrl,
subscribed_events: subscribed,
}),
});
if (!createdWebhook.ok) throw new Error(await createdWebhook.text());
webhook = await createdWebhook.json();
}
const webhookId = webhook.id;
const signingSecret = webhook.signing_secret;
// A reused endpoint may be disabled, or subscribed to other events.
if (!webhook.enabled) {
await fetch(`${BASE_URL}/webhooks/${webhookId}/enable/`, { method: "POST", headers: HEADERS });
}
if ([...webhook.subscribed_events].sort().join() !== [...subscribed].sort().join()) {
await fetch(`${BASE_URL}/webhooks/${webhookId}/`, {
method: "POST",
headers: HEADERS,
body: JSON.stringify({
name: webhook.name,
description: webhook.description,
subscribed_events: subscribed,
}),
});
}Coris sends no webhook for account detail changes. Your team works alerts under Merchant Alerts in the portal.
Read the upload response
Compare file_hash, a SHA-256 of what you sent, with your own hash.
{
"id": "b6e1d0f4-92c7-4a3e-8d15-2f7c9a0e4b68",
"file_name": "merchants.csv",
"file_type": "YOUR_FILE_TYPE",
"file_hash": "9b1f4c2ad7e36058c1a4e7d20f6b83c5a91d7e40c2b86f3d15ae07c94b62d8f1",
"created_at": 1762300800,
"updated_at": 1762300805,
"status": "completed"
}For sponsor banks
A sponsor bank answers for the merchants that its ISOs and PayFacs board. Coris holds those merchants in one portfolio, tagged by program, so your team can oversee third-party risk ISO by ISO.
| You need | How Coris supports it |
|---|---|
| Program and ISO oversight | Each account carries boarded_iso and sponsor_bank, from files or the API. |
| ISO access to its own merchants only | An ISO-scoped key. |
| One ISO's portfolio | List accounts with boarded_iso or sponsor_bank. |
| How checks and alerts work, for compliance | How Coris checks work and Monitoring and alerts. |
ISO-scoped keys
A workspace Admin or Developer creates these keys in the portal, for production or the sandbox. Coris turns the API keys page on per workspace, so ask for it if you do not see it.
- Each key is limited to one or more ISOs that already have accounts in your portfolio.
- Lists return only that key's ISOs. Another ISO's account returns
404. - An endpoint that scoped keys cannot use returns
403. - On create, a key with one ISO sets
boarded_isofor you. A key with several ISOs must send one of them, or the call returns400. - Scoped keys never see counts of linked accounts or the bank's own risk reports.
Filter the portfolio by ISO
Each filter returns the accounts with that value. search also matches both, as a partial match.
curl -s -G "$CORIS_BASE_URL/accounts/" \
-H "Authorization: Token $CORIS_API_KEY" \
--data-urlencode "boarded_iso=Acme Payments" \
--data-urlencode "limit=25" | jq '[.[] | {id, reference_id, boarded_iso, sponsor_bank}]'response = requests.get(
f"{BASE_URL}/accounts/",
headers=HEADERS,
params={"boarded_iso": "Acme Payments", "limit": 25},
)
response.raise_for_status()
for account in response.json():
print(account["id"], account["reference_id"], account.get("boarded_iso"))const params = new URLSearchParams({ boarded_iso: "Acme Payments", limit: "25" });
const isoAccounts = await fetch(`${BASE_URL}/accounts/?${params}`, { headers: HEADERS });
for (const account of await isoAccounts.json()) {
console.log(account.id, account.reference_id, account.boarded_iso);
}Set boarded_iso and sponsor_bank
boarded_iso and sponsor_bankBoth are free text, so keep one spelling per program. Merchant files can carry both. Through the API, set them with Update an account, as in step 3, because the sandbox drops them on create.
What to do next
| Event | Guide |
|---|---|
alert.created | Monitor your portfolio |
transaction.alert.created | Triage transaction alerts |
| A chargeback | Respond to disputes |
For your risk, compliance and security reviewers: How Coris checks work, Monitoring and alerts explained and Security and compliance.
FAQ
What if the reference_id already exists?
reference_id already exists?The API returns 409. Read that reference_id with GET, then update the account. If GET returns 404, the account was deleted, and you need a new reference_id.
Do I need to start monitoring?
No. New accounts are monitored by default.
Can an ISO see only its own merchants?
Yes, with an ISO-scoped key.
Updated about 2 hours ago