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.
- Coris → Reviewer1Alert in the Transaction Alerts queue
- Reviewer → Coris2Decide in the portal
- Coris → Your systems3Webhooktransaction.alert.createdoptional
- Coris → Your systems4Webhooktransaction.alert.updatedoptional
- Your systems → Coris5POST
/ entity_transaction_tags/ {entity_id}/optional
For reviewers
Reviewers work risky payments on the Transaction Alerts page in the portal.
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.
| Field | What it means |
|---|---|
| Risk score | Coris's risk score for the payment. A higher score means more risk. |
| Source | What opened the alert: a Coris check, a Stripe Radar review or a Stripe early fraud warning. |
| Triggered rule | The rule that fired on this payment. |
| Amount, payment method and payer | What was paid, with which card or account, and by whom. |
| AVS and CVV | Whether the billing address and the card security code matched what the issuer holds. |
| IP-shipping distance | How far the payer's IP address is from the shipping address. A large gap can point to fraud. |
| Related transactions | Other payments that share details with this one, such as the card, email or IP address. |
To record an outcome:
- Open Transaction Alerts, and sort by risk score and time remaining.
- Open an alert, and review the payment, its related transactions and the account.
- Pick an action. Clear Alert approves the payment. Report as Fraud marks it as fraud, and you can refund it at the same time.
- 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
fiwebhook_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 ;;
esactag_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"
}
}| Field | Meaning | What to do |
|---|---|---|
id | The ID of this event. | Use it to skip duplicate deliveries. |
data.account.reference_id | Your merchant ID. | Find the merchant in your system. |
data.transaction_id | On 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
Updated about 2 hours ago