Triage transaction alerts

Work transaction alerts in the Coris portal, with optional webhooks to route them and tags to record outcomes in your systems.

Reviewers work risky payments in the Coris portal. Add a webhook to route alerts to your tools, and a tag to record outcomes.

CorisReviewerYour systems
  1. Coris → Reviewer
    1Alert in the Transaction Alerts queue
  2. Reviewer → Coris
    2Decide in the portal
  3. Coris → Your systems
    3Webhooktransaction.alert.createdoptional
  4. Coris → Your systems
    4Webhooktransaction.alert.updatedoptional
  5. Your systems → Coris
    5POST /entity_transaction_tags/{entity_id}/optional

For reviewers

Reviewers work risky payments on the Transaction Alerts page in the portal.

Product view of a transaction alert, showing the payment amount, the masked card number, the billing ZIP code and the Coris risk evaluation

Product view from coris.ai, cropped and simplified for illustration. It shows (1) the amount, (2) the payment method and billing ZIP, and (3) the risk evaluation.

FieldWhat it means
Risk scoreCoris's risk score for the payment. A higher score means more risk.
SourceWhat opened the alert: a Coris check, a Stripe Radar review or a Stripe early fraud warning.
Triggered ruleThe rule that fired on this payment.
Amount, payment method and payerWhat was paid, with which card or account, and by whom.
AVS and CVVWhether the billing address and the card security code matched what the issuer holds.
IP-shipping distanceHow far the payer's IP address is from the shipping address. A large gap can point to fraud.
Related transactionsOther payments that share details with this one, such as the card, email or IP address.

To record an outcome:

  1. Open Transaction Alerts, and sort by risk score and time remaining.
  2. Open an alert, and review the payment, its related transactions and the account.
  3. Pick an action. Clear Alert approves the payment. Report as Fraud marks it as fraud, and you can refund it at the same time.
  4. Add a note with what you checked. The alert timeline keeps each action and who took it.

The actions you see depend on how the payment reached Coris.

For developers

Step 1 (optional): Route alerts to your tools

Subscribe to events if your team tracks work outside the portal.

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`);
}
WEBHOOK_URL="https://hooks.acmeplatform.example/coris/transaction-alerts"
SUBSCRIBED='["transaction.alert.created", "transaction.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/transaction-alerts"
subscribed = ["transaction.alert.created", "transaction.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/transaction-alerts";
const subscribed = ["transaction.alert.created", "transaction.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.

Verify each delivery. See Webhooks.

Step 2 (optional): Record the outcome in your systems

Create a tag once, then apply it to a transaction you pushed to Coris. This sample pushes a test transaction and uses its id.

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;
TRANSACTION_ID=$(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)\",
    \"amount\": 12500,
    \"currency\": \"USD\",
    \"status\": \"Completed\",
    \"payer\": { \"payment_method\": \"card\" }
  }" | jq -r '.id')
response = requests.post(
    f"{BASE_URL}/accounts/{account_id}/transactions/",
    headers=HEADERS,
    json={
        "transaction_id": f"txn-{int(time.time())}",
        "amount": 12500,
        "currency": "USD",
        "status": "Completed",
        "payer": {"payment_method": "card"},
    },
)
response.raise_for_status()
transaction_id = response.json()["id"]
const pushed = await fetch(`${BASE_URL}/accounts/${accountId}/transactions/`, {
  method: "POST",
  headers: HEADERS,
  body: JSON.stringify({
    transaction_id: `txn-${Date.now()}`,
    amount: 12500,
    currency: "USD",
    status: "Completed",
    payer: { payment_method: "card" },
  }),
});
if (!pushed.ok) throw new Error(await pushed.text());
const transactionId = (await pushed.json()).id;

Tagging needs tag management permissions on the key's user, and without them the call returns 403. Ask for them. Every call to apply a tag needs entity_type.

http_status=$(curl -s -o /tmp/coris_body -w '%{http_code}' -X POST "$CORIS_BASE_URL/transaction_tags/" \
  -H "Authorization: Token $CORIS_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{ \"name\": \"review_outcome_$(date +%s)\", \"description\": \"Outcome of payment review\", \"type\": \"String\" }")

case "$http_status" in
  2*)
    TAG_ID=$(jq -r '.id' /tmp/coris_body)
    curl -s -X POST "$CORIS_BASE_URL/entity_transaction_tags/$TRANSACTION_ID/?entity_type=transaction" \
      -H "Authorization: Token $CORIS_API_KEY" \
      -H "Content-Type: application/json" \
      -d "{ \"transaction_tag_id\": \"$TAG_ID\", \"transaction_tag_value\": \"approved\" }"
    ;;
  *) echo "HTTP $http_status: $(cat /tmp/coris_body)" >&2 ;;
esac
tag_response = requests.post(
    f"{BASE_URL}/transaction_tags/",
    headers=HEADERS,
    json={
        "name": f"review_outcome_{int(time.time())}",
        "description": "Outcome of payment review",
        "type": "String",
    },
)
if not tag_response.ok:
    raise RuntimeError(f"HTTP {tag_response.status_code}: {tag_response.text}")

response = requests.post(
    f"{BASE_URL}/entity_transaction_tags/{transaction_id}/",
    params={"entity_type": "transaction"},
    headers=HEADERS,
    json={"transaction_tag_id": tag_response.json()["id"], "transaction_tag_value": "approved"},
)
print(response.status_code, response.text)
const tagResponse = await fetch(`${BASE_URL}/transaction_tags/`, {
  method: "POST",
  headers: HEADERS,
  body: JSON.stringify({
    name: `review_outcome_${Date.now()}`,
    description: "Outcome of payment review",
    type: "String",
  }),
});
if (!tagResponse.ok) throw new Error(`HTTP ${tagResponse.status}: ${await tagResponse.text()}`);
const tagId = (await tagResponse.json()).id;

const tagged = await fetch(
  `${BASE_URL}/entity_transaction_tags/${transactionId}/?entity_type=transaction`,
  {
    method: "POST",
    headers: HEADERS,
    body: JSON.stringify({ transaction_tag_id: tagId, transaction_tag_value: "approved" }),
  },
);
console.log(tagged.status, await tagged.text());

Try POST /transaction_tags/ in the API explorer.

To tag a Stripe payment, send its Stripe charge ID with entity_type=stripe_charge. See Set a transaction tag. For Stripe payments, you can also send context with Update transaction metadata.

Read the response

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

{
  "id": "2a1b3c4d-5e6f-4081-92a3-b4c5d6e7f801",
  "type": "transaction.alert.created",
  "timestamp": 1762300800,
  "data": {
    "account": {
      "reference_id": "acme-plumbing-001"
    },
    "transaction_id": "3b5f9e2a-7c1d-4e8f-9a0b-1c2d3e4f5a6b"
  }
}
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.
data.transaction_idOn a Stripe alert, the Coris ID of the payment. It is not the id of a pushed transaction.Store it with the alert. As a tag entity_id it returns 404.

What to do next

Route each alert by its risk score in the portal. A higher score means more risk, so send the highest-scoring alerts to a senior reviewer first, and set your cutoffs to your risk appetite. For the business rating and CorShield scales, see How to read Coris scores.

FAQ

What if I have no portal login?

Request a login. Reviewers do this work in the portal. Alert endpoints are only in the partner reference, which you request the same way.

Do I need to push transactions for this?

Alerts come from the payment data Coris already receives through a processor connection or files, so you do not need to push transactions.

Why did I not receive an event?

Check that the endpoint is enabled and subscribed. See Webhooks.

Can I trigger a test alert in the sandbox?

The public API has no test trigger for alerts. To test your endpoint, subscribe to account.tag.created and apply a tag, as in Monitor your portfolio.

Next: Respond to disputes


Did this page help you?