# Safe-Link — partner integration

Audience: an engineer at a bank, a telco, a messaging platform or a helpdesk tool
who wants to show CyberGuardian verdicts inside their own product.

Base URL: `https://iesciberguardianapp.abacusai.app`
Specification: [`/openapi.yaml`](/openapi.yaml)

## 1. Ask for a verdict

```
POST /api/v1/check/link
Content-Type: application/json

{ "url": "https://paypa1-secure-login.top/verify?id=99",
  "context": "api",
  "client_id": "helpdesk-42" }
```

The response is the whole product surface:

| Field | Use it for |
| --- | --- |
| `score`, `level` | The badge: `82% — High risk` |
| `action_line` | The single line you show the person |
| `explain_short` | Ten words at most, for a tooltip or a list row |
| `explain_long` | Two lines; the second is a concrete next step |
| `top_flag`, `flags[]` | Your own analytics and triage rules |
| `token_signed` | Proof the verdict is ours — see below |
| `request_id` | The handle for outcomes, appeals and reports |
| `explain_url` | A page you can link to for the full reasoning |

Rate limits: 20 checks per hour for an anonymous caller, 120 for a signed-in
one, per connection. On `429` honour `Retry-After`; do not retry in a tight loop.

Only send the address. Do not send page bodies, message bodies, cookies,
headers, card numbers or one-time codes: the service does not need them and will
not use them.

## 2. Verify the verdict yourself

`token_signed` is a JWT-shaped token signed with **PS256** (RSASSA-PSS,
SHA-256, salt length equal to the digest). Verify it locally — that is the point
of signing it.

1. `GET /.well-known/cyberguardian-keys` → a JWK set. Cache it for an hour.
2. Read the token header, take `kid`, find the matching JWK.
3. Verify the signature over `base64url(header) + "." + base64url(payload)`.
4. Check `issuer == "cyberguardian"`, `expires_at` is in the future and
   `issued_at` is not in the future. Allow 60 seconds of clock skew.
5. Check `url_hash` equals your own sha256 of the canonical URL if you keep one.

Payload fields: `request_id`, `url_hash`, `score`, `level`, `top_flag`,
`model_version`, `issued_at`, `expires_at`, `issuer`, and `aud` when the verdict
was issued for a named partner. `issued_at` and `expires_at` are Unix seconds.

Node.js, no dependencies:

```js
const crypto = require('crypto');

async function verify(token) {
  const [h, p, s] = token.split('.');
  const header = JSON.parse(Buffer.from(h, 'base64url'));
  if (header.alg !== 'PS256') throw new Error('unexpected algorithm');

  const { keys } = await (await fetch(BASE + '/.well-known/cyberguardian-keys')).json();
  const jwk = keys.find((k) => k.kid === header.kid);
  if (!jwk) throw new Error('unknown kid');

  const key = crypto.createPublicKey({ key: jwk, format: 'jwk' });
  const ok = crypto.verify(
    'sha256',
    Buffer.from(`${h}.${p}`),
    { key, padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
      saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST },
    Buffer.from(s, 'base64url')
  );
  if (!ok) throw new Error('bad signature');

  const payload = JSON.parse(Buffer.from(p, 'base64url'));
  const now = Math.floor(Date.now() / 1000);
  if (payload.issuer !== 'cyberguardian') throw new Error('bad issuer');
  if (payload.expires_at + 60 < now) throw new Error('expired');
  return payload;
}
```

Python, using `cryptography`:

```python
import base64, json, hashlib, requests
from cryptography.hazmat.primitives.asymmetric import padding, rsa
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.serialization import load_der_public_key

def b64(seg): return base64.urlsafe_b64decode(seg + '=' * (-len(seg) % 4))

def verify(token, base):
    h, p, s = token.split('.')
    header = json.loads(b64(h))
    assert header['alg'] == 'PS256'
    jwk = next(k for k in requests.get(base + '/.well-known/cyberguardian-keys').json()['keys']
               if k['kid'] == header['kid'])
    n = int.from_bytes(b64(jwk['n']), 'big'); e = int.from_bytes(b64(jwk['e']), 'big')
    key = rsa.RSAPublicNumbers(e, n).public_key()
    key.verify(b64(s), f'{h}.{p}'.encode(),
               padding.PSS(mgf=padding.MGF1(hashes.SHA256()),
                           salt_length=hashes.SHA256().digest_size),
               hashes.SHA256())
    return json.loads(b64(p))
```

`POST /api/v1/verify` does the same thing server-side. It exists so you can prove
the integration on day one. Stop calling it once your own verification works: a
verification you delegate to us proves nothing about us.

## 3. Key rotation

- Keys are identified by `kid`. Never pin a single `kid` in code.
- On rotation both the new and the previous key are published in the JWK set for
  a grace period of at least 7 days, and only the new key signs.
- Refetch the JWK set when you see an unknown `kid`, at most once a minute, then
  cache again. A verdict whose `kid` is still unknown after a refetch is not
  trustworthy: treat it as unsigned.
- Emergency rotation on suspected key compromise removes the old `kid`
  immediately; see the runbook.

## 4. Failure behaviour — what your users should see

| Situation | What to show |
| --- | --- |
| `429` | "Too many checks right now. Try again in a minute." Never a verdict. |
| `500`, `502`, timeout | "We could not check this link. Treat it as unverified and open the company's app yourself." |
| `token_signed` is null | Show the verdict but not any "verified" mark. It means the deployment has no signing key. |
| Signature fails | Discard the verdict entirely and show the unverified message. Log the `request_id`. |
| Verdict expired (`expires_at` past) | Ask again. Do not cache a verdict beyond `ttl_seconds`. |

Never fail *open* with reassuring wording. The absence of a verdict is not a
clean bill of health, and the copy must not imply it is.

## 5. Outcomes — the metric that matters

If your surface lets the person continue to the link, tell us what they did:

```
POST /api/v1/outcome
{ "request_id": "...", "outcome": "avoided" | "opened" | "copied" }
```

This is the only input to pre-click prevention conversion, the number the
product is judged on. It is accepted once per verdict and never overwritten.
Without it we can measure verdicts but not prevention.

## 6. Appeals and reports

- `POST /api/v1/appeal` with `request_id` and a `note` — a human reviewer decides
  within 24 hours, and the decision is recorded with the reviewer's name.
- `POST /api/v1/report` with `request_id` and `evidence` — new information about
  a link.
- `GET /api/v1/whitelist/{url_hash}` — whether one exact address carries an
  exception. Takes a hash, so you can ask without sending us the link.

Surface both in your UI. An assessment a user cannot contest gets ignored, and
then the warnings get ignored too.

## 7. Limits you should state to your own users

- The service reads the **address**, not the page behind it. A clean address on a
  site that is compromised later will read clean.
- Scoring is probabilistic. False positives happen, which is why appeals exist.
- Safe-Link has no accuracy benchmark of its own yet. Do not quote the platform's
  content-analysis benchmark as a link-detection figure; they measure different
  things.
- This automated assessment is informational only. Not forensic evidence.
