Monitor your portfolio

Start merchant monitoring on accounts, receive alert webhooks, label accounts with tags and stop monitoring when a merchant leaves.

New accounts are monitored by default, and Coris sends alerts to your webhook and the portal.

YouCoris
  1. You → Coris
    1POST /webhooks/
  2. You → Coris
    2POST /accounts/
  3. Monitoring finds a change
  4. Coris → You
    3Webhookalert.created
  5. You → Coris
    4POST /accounts/{account_id}/account_tags/
  6. You → Coris
    5POST /accounts/{account_id}/stop_monitoring/
  7. You → Coris
    6POST /accounts/{account_id}/monitor/

For reviewers

Reviewers work merchant alerts on the Merchant Alerts page in the portal. If you have no portal login, request one.

Product view of the Merchant Alerts page, with summary counts above a list of alerts showing risk score, business name, legal name, checkpoint and alert date

Product view from coris.ai, simplified for illustration. It shows (1) counts of open alerts, (2) search and filters, and (3) the alert list.

FieldWhat it means
Risk scoreThe merchant's Coris risk score. A higher score means more risk.
CheckpointWhat started the check, such as onboarding, scheduled monitoring or an account update.
RulesEach rule that fired, with its description, priority and the time it fired.
Queue and assigneeThe queue that holds the alert, and the person or team that owns it.
StatusWhere the alert stands, from open to closed. See Monitoring and alerts explained.

To record an outcome:

  1. Open the alert, and read the rules that fired and the account details.
  2. Add a note with what you found and what you checked.
  3. Pick a queue action. The action closes the alert as valid for a real risk, or invalid for a false alarm, or moves it on.

For developers

Route alerts to your systems, label reviewed accounts, and stop or restart monitoring by API.

Step 1: Set up your key and a test account

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`);
}

This call creates a test account for the steps below.

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: Subscribe to alerts

Use webhooks instead of polling the account list.

WEBHOOK_URL="https://hooks.acmeplatform.example/coris/alerts"
SUBSCRIBED='["alert.created", "alert.updated"]'

# 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/alerts"
subscribed = ["alert.created", "alert.updated"]

# 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/alerts";
const subscribed = ["alert.created", "alert.updated"];

// 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) {
  const enabled = await fetch(`${BASE_URL}/webhooks/${webhookId}/enable/`, {
    method: "POST",
    headers: { Authorization: HEADERS.Authorization },
  });
  if (!enabled.ok) throw new Error(await enabled.text());
}

const sameEvents = [...webhook.subscribed_events].sort().join() === [...subscribed].sort().join();
if (!sameEvents) {
  const updated = await fetch(`${BASE_URL}/webhooks/${webhookId}/`, {
    method: "POST",
    headers: HEADERS,
    body: JSON.stringify({
      name: webhook.name,
      description: webhook.description,
      subscribed_events: subscribed,
    }),
  });
  if (!updated.ok) throw new Error(await updated.text());
}

Try POST /webhooks/ in the API explorer.

Step 3 (optional): Label reviewed accounts

Tag an account when an alert closes, so that your systems and the portal agree.

Send mode and a new name, because a missing mode or a repeated name returns 500. This sample adds a timestamp.

http_status=$(curl -s -o /tmp/coris_body -w '%{http_code}' -X POST "$CORIS_BASE_URL/account_tags/" \
  -H "Authorization: Token $CORIS_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{ \"name\": \"risk_tier_$(date +%s)\", \"description\": \"Your risk tier\", \"type\": \"String\", \"mode\": \"ReadWrite\" }")

case "$http_status" in
  2*)
    TAG_ID=$(jq -r '.id' /tmp/coris_body)
    curl -s -X POST "$CORIS_BASE_URL/accounts/$ACCOUNT_ID/account_tags/" \
      -H "Authorization: Token $CORIS_API_KEY" \
      -H "Content-Type: application/json" \
      -d "{ \"account_tag_id\": \"$TAG_ID\", \"account_tag_value\": \"standard\" }"
    ;;
  *) echo "HTTP $http_status: $(cat /tmp/coris_body)" >&2 ;;
esac
tag_response = requests.post(
    f"{BASE_URL}/account_tags/",
    headers=HEADERS,
    json={
        "name": f"risk_tier_{int(time.time())}",
        "description": "Your risk tier",
        "type": "String",
        "mode": "ReadWrite",
    },
)
if not tag_response.ok:
    raise RuntimeError(f"HTTP {tag_response.status_code}: {tag_response.text}")

response = requests.post(
    f"{BASE_URL}/accounts/{account_id}/account_tags/",
    headers=HEADERS,
    json={"account_tag_id": tag_response.json()["id"], "account_tag_value": "standard"},
)
print(response.status_code, response.text)
const tagResponse = await fetch(`${BASE_URL}/account_tags/`, {
  method: "POST",
  headers: HEADERS,
  body: JSON.stringify({
    name: `risk_tier_${Date.now()}`,
    description: "Your risk tier",
    type: "String",
    mode: "ReadWrite",
  }),
});
if (!tagResponse.ok) throw new Error(`HTTP ${tagResponse.status}: ${await tagResponse.text()}`);
const tag = await tagResponse.json();

const tagged = await fetch(`${BASE_URL}/accounts/${accountId}/account_tags/`, {
  method: "POST",
  headers: HEADERS,
  body: JSON.stringify({ account_tag_id: tag.id, account_tag_value: "standard" }),
});
console.log(tagged.status, await tagged.text());

Try POST /account_tags/ in the API explorer.

Step 4: Stop monitoring

Stop monitoring an account when the merchant leaves. With no body, any run you started stays as it is. To stop that run too, send its checkpoint.

curl -s -X POST "$CORIS_BASE_URL/accounts/$ACCOUNT_ID/stop_monitoring/" \
  -H "Authorization: Token $CORIS_API_KEY"
response = requests.post(f"{BASE_URL}/accounts/{account_id}/stop_monitoring/", headers=HEADERS)
print(response.status_code, response.text)
const stop = await fetch(`${BASE_URL}/accounts/${accountId}/stop_monitoring/`, {
  method: "POST",
  headers: { Authorization: HEADERS.Authorization },
});
console.log(stop.status, await stop.text());

Try POST /accounts/{account_id}/stop_monitoring/ in the API explorer.

Step 5: Restart monitoring

Restart monitoring on an account you stopped earlier. Send no body.

curl -s -X POST "$CORIS_BASE_URL/accounts/$ACCOUNT_ID/monitor/" \
  -H "Authorization: Token $CORIS_API_KEY"
response = requests.post(f"{BASE_URL}/accounts/{account_id}/monitor/", headers=HEADERS)
print(response.status_code, response.text)
const restart = await fetch(`${BASE_URL}/accounts/${accountId}/monitor/`, {
  method: "POST",
  headers: { Authorization: HEADERS.Authorization },
});
console.log(restart.status, await restart.text());

Try POST /accounts/{account_id}/monitor/ in the API explorer.

Read the response

Your webhook receives an event like this when an alert opens.

{
  "id": "8f2d7e3a-1b45-47c1-b4a8-2bd0a9e1f001",
  "type": "alert.created",
  "timestamp": 1762300800,
  "data": {
    "account": {
      "reference_id": "acme-plumbing-001"
    }
  }
}
FieldMeaningWhat to do
idThe ID of this event.Use it to skip duplicate deliveries.
data.account.reference_idYour merchant ID.Find the merchant in your system.

What to do next

Act on the outcome your reviewer records in the portal.

OutcomeAction
The alert shows a real riskApply your policy, such as a limit change.
The alert is a false alarmNo action.
You need more from the merchantContact the merchant.

To read or update alerts by API, you need the partner reference. Ask for access.

FAQ

How often does Coris check an account?

Onboarding checks run once, when you create the account. Event checks run when new data arrives, and scheduled checks run daily, weekly, every two weeks or monthly. For timing, see Monitoring and alerts explained.

Do I keep receiving alerts after I stop monitoring?

Not from new monitoring runs, unless your setup has a check that also runs on stopped accounts. Open alerts stay in the portal.

Can I trigger a test alert in the sandbox?

The public API has no test trigger for alerts. To test your endpoint and signature check, add account.tag.created to subscribed_events in step 2, run it again, then apply a tag in step 3.

Next: Triage transaction alerts


Did this page help you?