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.

Load by file
  1. Step 1POST /file_uploads/
  2. Step 2PUT /file_uploads/{file_upload_id}/
Load by SFTP
  1. FeedSFTP feed
Load by API, then onboard
  1. Step 1POST /accounts/
  2. Step 2POST /accounts/{account_id}/persons/
  3. Onboard (optional)POST /merchant_profiler/
  1. Webhookalert.created
  2. PortalMerchant Alerts

Choose how you send data

You can mix both if your merchant IDs match.

CompareFilesAPI
Best forWhole portfolios, daily batchesOne merchant at a time
You sendMerchant, payment and chargeback recordsMerchant details and reference_id
You receivePortfolio, alerts and disputes in the portalAn account linked to a business

Setup checklist

Start with your key and rules.

Endpoints you use

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;

Try it.

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]" | jq
with 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());

Try it.

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" | jq
response = 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());

Try it.

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;

Try it.

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 }
  }' | jq
response = 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());

Try it.

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());

Try it.

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\" }
    }
  }" | jq
now = 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());

Try it.

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
fi
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.
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,
    }),
  });
}

Try it.

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 needHow Coris supports it
Program and ISO oversightEach account carries boarded_iso and sponsor_bank, from files or the API.
ISO access to its own merchants onlyAn ISO-scoped key.
One ISO's portfolioList accounts with boarded_iso or sponsor_bank.
How checks and alerts work, for complianceHow 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_iso for you. A key with several ISOs must send one of them, or the call returns 400.
  • 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);
}

Try it.

Set boarded_iso and sponsor_bank

Both 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

EventGuide
alert.createdMonitor your portfolio
transaction.alert.createdTriage transaction alerts
A chargebackRespond 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?

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.

Next: Integrate a SaaS platform


Did this page help you?