Appearance
Verify with a hosted link
Use this path when you do not use Sumsub, or you cannot collect the cardholder's selfie and liveness check. Agio gives you a signed link to a hosted verification page. The cardholder opens it and completes the missing steps there. You do not handle the selfie or any identity document.
The same steps apply when you send a Sumsub share token and the card issuer still needs more from the cardholder.
How it works
- Provision the cardholder and submit the application without
partnerKycShareToken. - Store the signed link when Agio returns one.
- Give the link to the cardholder. The cardholder takes the selfie and finishes the other steps on the hosted page.
- Wait for
card_application.status_changed. The webhook, not the cardholder's return to your app, tells you the result.
Where the link comes from
| When | Field |
|---|---|
| The application needs the cardholder when you submit it | applicationCompletionUrl in the mutation response |
| The status changes later, for example after a review | data.completionUrl in card_application.status_changed |
| You need the link again | application_completion_link on the application |
Agio returns a link only while the status is NEEDSINFORMATION, NEEDSVERIFICATION, NOTSTARTED or TOSNOTACCEPTED. For every other status, the link is null. Do not show an old link after the status changes.
Open the link exactly as Agio returns it. Its parameters carry a signature, and the hosted page rejects a changed link.
The card issuer can issue a new link while the status stays the same. Subscribe to CARD_APPLICATION_COMPLETION_LINK_CHANGED to receive card_application.completion_link_changed with the latest link. Without it, a link that you stored can be out of date.
Store the link
These examples use the partner_query and PartnerQuery helpers from the quickstart. Your own session code supplies the signed-in user.
python
from fastapi import APIRouter, Depends, HTTPException
from partner_request import partner_query
from app.auth import current_user
from app.store import get_completion_url, save_completion_url
router = APIRouter()
APPLY = """
mutation Apply($input: CreatePartnerCardApplicationInput!) {
createCardApplicationForPartnerUser(input: $input) {
success
cardApplicationId
applicationStatus
applicationCompletionUrl
errorCode
error
}
}
"""
@router.post("/kyc/application")
def submit_application(user=Depends(current_user)):
result = partner_query(APPLY, {"input": user.card_application_input()})["createCardApplicationForPartnerUser"]
if not result["success"]:
raise HTTPException(422, result["errorCode"] or result["error"])
save_completion_url(user.id, result["applicationCompletionUrl"])
return {"status": result["applicationStatus"], "needsCardholder": result["applicationCompletionUrl"] is not None}
@router.get("/kyc/completion-url")
def completion_url(user=Depends(current_user)):
url = get_completion_url(user.id)
if not url:
raise HTTPException(404, "No verification step is waiting for this cardholder")
return {"url": url}go
package partner
import (
"encoding/json"
"net/http"
)
func SubmitApplication(w http.ResponseWriter, r *http.Request) {
user := CurrentUser(r)
var out struct {
Result ApplyResult `json:"createCardApplicationForPartnerUser"`
}
vars := map[string]any{"input": user.CardApplicationInput()}
if err := PartnerQuery(r.Context(), applyMutation, vars, &out); err != nil {
http.Error(w, "application request failed", http.StatusBadGateway)
return
}
if !out.Result.Success {
http.Error(w, "application rejected", http.StatusUnprocessableEntity)
return
}
SaveCompletionURL(r.Context(), user.ID, out.Result.ApplicationCompletionURL)
json.NewEncoder(w).Encode(map[string]any{
"status": out.Result.ApplicationStatus,
"needsCardholder": out.Result.ApplicationCompletionURL != nil,
})
}
func CompletionURL(w http.ResponseWriter, r *http.Request) {
url, ok := GetCompletionURL(r.Context(), CurrentUser(r).ID)
if !ok {
http.Error(w, "no verification step is waiting for this cardholder", http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(map[string]string{"url": url})
}applyMutation and ApplyResult are the same as in the share token example.
Update the link from webhooks
Verify each delivery on the raw body before you parse it. Then save the status and the link. Use eventId to ignore a delivery that you already handled.
python
import hashlib
import hmac
import json
import os
import time
from fastapi import APIRouter, HTTPException, Request
from app.store import mark_event_handled, save_application_status
router = APIRouter()
SIGNING_SECRET = os.environ["AGIO_WEBHOOK_SIGNING_SECRET"].encode()
@router.post("/webhooks/agio")
async def agio_webhook(request: Request):
raw = await request.body()
timestamp = request.headers.get("x-agio-timestamp", "")
signature = request.headers.get("x-agio-signature", "")
if not timestamp.isdigit() or abs(time.time() * 1000 - int(timestamp)) > 5 * 60 * 1000:
raise HTTPException(401, "Stale or missing timestamp")
expected = hmac.new(SIGNING_SECRET, timestamp.encode() + b"." + raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
raise HTTPException(401, "Invalid signature")
event = json.loads(raw)
if event["eventName"] == "card_application.status_changed" and mark_event_handled(event["eventId"]):
data = event["data"]
save_application_status(
card_application_id=data["cardApplicationId"],
status=data["newStatus"],
next_step=data["nextStep"],
completion_url=data["completionUrl"],
)
return {"received": True}go
package partner
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"os"
"strconv"
"time"
)
type applicationEvent struct {
EventName string `json:"eventName"`
EventID string `json:"eventId"`
Data struct {
CardApplicationID int `json:"cardApplicationId"`
NewStatus string `json:"newStatus"`
NextStep *string `json:"nextStep"`
CompletionURL *string `json:"completionUrl"`
} `json:"data"`
}
func AgioWebhook(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
timestamp := r.Header.Get("x-agio-timestamp")
sent, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || time.Since(time.UnixMilli(sent)).Abs() > 5*time.Minute {
http.Error(w, "stale or missing timestamp", http.StatusUnauthorized)
return
}
mac := hmac.New(sha256.New, []byte(os.Getenv("AGIO_WEBHOOK_SIGNING_SECRET")))
mac.Write([]byte(timestamp + "."))
mac.Write(raw)
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(r.Header.Get("x-agio-signature"))) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
var event applicationEvent
if err := json.Unmarshal(raw, &event); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
if event.EventName == "card_application.status_changed" && MarkEventHandled(r.Context(), event.EventID) {
SaveApplicationStatus(r.Context(), event.Data.CardApplicationID, event.Data.NewStatus, event.Data.NextStep, event.Data.CompletionURL)
}
w.WriteHeader(http.StatusOK)
}Give the link to the cardholder
Open the link in a new tab from a click, or redirect the cardholder to it. Do not load it in an iframe. The hosted page asks for camera access for the selfie, so the cardholder's browser must allow the camera for that page.
This Next.js client component asks your server for the link and opens it in a new tab. The original tab then waits for the status that your webhook saves.
tsx
"use client";
import { useState } from "react";
const API_URL = process.env.NEXT_PUBLIC_API_URL;
export function VerifyIdentityButton() {
const [message, setMessage] = useState<string | null>(null);
async function openVerification() {
const tab = window.open("", "_blank");
const response = await fetch(`${API_URL}/kyc/completion-url`, { credentials: "include" });
if (!response.ok) {
tab?.close();
setMessage(response.status === 404 ? "No verification step is waiting for you." : "We could not open verification. Try again.");
return;
}
const { url } = (await response.json()) as { url: string };
if (tab) {
tab.location.href = url;
} else {
window.location.assign(url);
}
setMessage("Finish verification in the new tab. We will update this page when the review is complete.");
}
return (
<div>
<button type="button" onClick={openVerification}>
Verify your identity
</button>
{message && <p role="status">{message}</p>}
</div>
);
}The component opens the tab before the request so that the browser does not block it as a pop-up. If the browser blocks it anyway, the component redirects the current tab.
To bring the cardholder back to your site, append a redirect query parameter with your HTTPS return URL to the link, URL-encoded. When the cardholder finishes, the hosted page returns them to that URL. Do not rely on the return to learn the result. Wait for the webhook.
Test in development
Use the development endpoint https://dev.api.agiodigital.com/partner/cards/graphql and Agio development credentials. Never test with production credentials. In development, the card issuer sets the application status from the cardholder's last name. A last name that contains NeedsVerification, with no space, asks for more verification. A last name that contains Approved approves the application.
Before you start
- Start your webhook receiver.
- Expose it on a public HTTPS URL, for example with
ngrok http 8000. Agio does not deliver to localhost. - Subscribe with the event
CARD_APPLICATION_STATUS_CHANGED. Save the signing secret inAGIO_WEBHOOK_SIGNING_SECRET. - Check that your receiver accepts the test delivery with a 2xx response. A 401 from your receiver means that its signature check is wrong.
Steps
- Provision a cardholder with the last name
TestNeedsVerificationand a new email address. - Submit the application without
partnerKycShareToken. Expectsuccess: true, a status that waits on the cardholder, and anapplicationCompletionUrl. - In your app, click the verify button. Expect the hosted page to open in a new tab.
- Complete the steps on the hosted page. In development, the selfie step accepts any face.
- Check your receiver for
card_application.status_changedwithnewStatus,nextStepandcompletionUrl.
The last-name rule can keep the status fixed in development, even after the cardholder finishes the hosted page. To test approval, provision a second cardholder with the last name TestApproved.
Checks that must fail
| Test | Expected result |
|---|---|
Submit for a cardholder with the last name TestApproved | No applicationCompletionUrl. Your app shows no verify button. |
| Send a saved delivery to your receiver with one changed byte | Your receiver returns 401 |
| Send a saved delivery again after six minutes | Your receiver returns 401 for the old timestamp |
| Resend a delivery that you already handled | Your receiver returns 2xx and saves nothing new |
| Remove one parameter from the link and open it | The hosted page rejects the link |
Smoke test script
This script runs steps 1 and 2 with the quickstart helper. It reads the agreement-set version and the program's agreement keys from your environment, because Agio supplies them for your program. The script stops if the endpoint is not the development endpoint.
python
import hashlib
import os
import uuid
from datetime import datetime, timezone
import httpx
from partner_request import partner_query
if "dev.api.agiodigital.com" not in os.environ.get("AGIO_PARTNER_ENDPOINT", "https://dev.api.agiodigital.com"):
raise SystemExit("Run this script against development only")
AGREEMENT_URLS = {
"card-terms-us": "https://www.agiodigital.com/agio-digital-ltd-card-terms-us-program.md",
"account-opening-privacy-us": "https://www.agiodigital.com/agio-digital-ltd-account-opening-privacy-notice-us-program.md",
}
CREATE_ORGANIZATION = """
mutation ($input: CreatePartnerCustomerOrganizationInput!) {
createPartnerCustomerOrganization(input: $input) { success organizationId error }
}"""
CREATE_CARDHOLDER = """
mutation ($input: CreatePartnerCardUserInput!) {
createPartnerCardUser(input: $input) { success cardUserId errorCode errorMessage }
}"""
APPLY = """
mutation ($input: CreatePartnerCardApplicationInput!) {
createCardApplicationForPartnerUser(input: $input) {
success cardApplicationId applicationStatus applicationCompletionUrl errorCode error
}
}"""
def document_hashes() -> list[dict]:
hashes = []
for key in os.environ.get("AGIO_AGREEMENT_KEYS", "card-terms-us,account-opening-privacy-us").split(","):
response = httpx.get(AGREEMENT_URLS[key], timeout=30)
response.raise_for_status()
hashes.append({"key": key, "sha256": hashlib.sha256(response.content).hexdigest()})
return hashes
organization = partner_query(CREATE_ORGANIZATION, {"input": {"name": "Hosted link test"}})["createPartnerCustomerOrganization"]
assert organization["success"], organization
cardholder = partner_query(CREATE_CARDHOLDER, {"input": {
"customerOrganizationId": organization["organizationId"],
"firstName": "Alice",
"lastName": "TestNeedsVerification",
"email": f"hosted-link-{uuid.uuid4().hex[:8]}@example.com",
"addressLine1": "1 Main St",
"addressCity": "Brooklyn",
"addressRegion": "NY",
"addressPostalCode": "11201",
"addressCountryCode": "US",
"phoneCountryCode": "1",
"phoneNumber": "2025551234",
"birthDate": "1990-01-15",
"nationalId": "123456789",
}})["createPartnerCardUser"]
assert cardholder["success"], cardholder
application = partner_query(APPLY, {"input": {
"cardUserId": cardholder["cardUserId"],
"createWallet": True,
"occupation": "Software Developers",
"annualSalary": "100000",
"accountPurpose": "Business expenses",
"expectedMonthlyVolume": "5000",
"isTermsOfServiceAccepted": True,
"agreementsAcceptance": {
"agreementVersion": os.environ["AGIO_AGREEMENT_VERSION"],
"documentHashes": document_hashes(),
"acceptedAt": datetime.now(timezone.utc).isoformat(),
},
}})["createCardApplicationForPartnerUser"]
assert application["success"], application
assert application["applicationCompletionUrl"], f"No link for status {application['applicationStatus']}"
print("Status:", application["applicationStatus"])
print("Open this link in a browser:", application["applicationCompletionUrl"])Remove createWallet and send walletAddress if Agio has not configured a partner treasury for you.