# Upollo Webhooks

## When to Use This Skill

* How do I receive Upollo webhooks?
* How do I verify the Upollo `Upollo-Signature` header?
* How do I verify an Upollo HMAC-SHA512 signature over the raw body?
* How do I react to an Upollo fraud flag like `ACCOUNT_SHARING` or `MULTIPLE_ACCOUNTS`?
* How do I handle the Upollo `action` (CHALLENGE / DENY / PERMIT / OFFER / LOG)?
* Why is my Upollo webhook signature verification failing?

## How Upollo Webhooks Work

Upollo is a fraud / risk-detection platform. Unlike most providers, Upollo
webhooks are not a subscription to discrete named events. A webhook fires
whenever Upollo flags a user (for example account sharing or
multi-accounting). Each delivery is an analysis describing the user, the
device, the recommended `action`, and the list of `flags` that were raised.

Every delivery is signed. Upollo computes an HMAC-SHA512 of the raw request
body, keyed with your webhook secret, and sends it in the `Upollo-Signature`
header. Verify it against the raw body before doing anything else.

> Not Standard Webhooks. Upollo does not use the Standard Webhooks
> (`webhook-id` / `webhook-timestamp` / `webhook-signature`) scheme. It uses a
> single `Upollo-Signature` header with `t:` and `s0:` parts (below).

## Verification (core)

`Upollo-Signature` carries two comma-separated parts:

```
Upollo-Signature: t:1706352000,s0:3f9a...<128 hex chars>

```

* `t` — Unix timestamp of the delivery (seconds). Use it for optional replay
  protection; it is not part of the signed content.
* `s0` — `HMAC-SHA512(secret, rawBody)`. Recompute over the raw body and
  compare timing-safe.

> Digest encoding. Upollo's docs don't state hex vs base64. The `s0:` prefix
> and observed 128-char values indicate lowercase hex. The snippets below
> compute the digest once and accept hex or base64 so they keep working
> either way — confirm hex against one live delivery, then you can drop base64.

Node:

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

function verifyUpolloWebhook(rawBody, signatureHeader, secret) {
  if (!signatureHeader) return false;
  const s0 = Object.fromEntries(
    signatureHeader.split(',').map((p) => {
      const i = p.indexOf(':');
      return [p.slice(0, i).trim(), p.slice(i + 1).trim()];
    })
  ).s0;
  if (!s0) return false;
  const digest = crypto.createHmac('sha512', secret).update(rawBody).digest();
  return [digest.toString('hex'), digest.toString('base64')].some((expected) => {
    try {
      return crypto.timingSafeEqual(Buffer.from(s0), Buffer.from(expected));
    } catch {
      return false; // length mismatch → not a match
    }
  });
}

```

Python:

```python
import hmac, hashlib, base64

def verify_upollo_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool:
    if not signature_header:
        return False
    parts = dict(p.split(":", 1) for p in signature_header.split(",") if ":" in p)
    s0 = parts.get("s0", "").strip()
    if not s0:
        return False
    digest = hmac.new(secret.encode(), raw_body, hashlib.sha512).digest()
    return (
        hmac.compare_digest(s0, digest.hex())
        or hmac.compare_digest(s0, base64.b64encode(digest).decode())
    )

```

> For complete handlers with action dispatch, flag handling, and tests, see
> [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/upollo-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/upollo-webhooks/examples/nextjs/),
> [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/upollo-webhooks/examples/fastapi/).

## The Payload: Action + Flags

Because there is no event name, dispatch on the recommended `action` and the
`flags` array. The payload is Upollo's analysis object (fields shown in
protobuf-JSON camelCase):

```json
{
  "action": "CHALLENGE",
  "eventType": "LOGIN",
  "flags": [
    {
      "type": "ACCOUNT_SHARING",
      "firstFlagged": "2026-07-01T12:00:00Z",
      "mostRecentlyFlagged": "2026-07-27T09:00:00Z"
    }
  ],
  "userInfo": { "userId": "user_123", "userEmail": "user@example.com" },
  "deviceInfo": { "deviceId": "dev_abc", "deviceClass": "DEVICE_CLASS_DESKTOP" },
  "isUsingVpn": false
}

```

> Upollo's enums carry prefixes at the source (`OUTCOME_CHALLENGE`,
> `EVENT_TYPE_LOGIN`, `FLAG_TYPE_UNSPECIFIED`); observed webhook payloads use the
> short form (`CHALLENGE`, `LOGIN`, `ACCOUNT_SHARING`). The example handlers
> normalize by stripping the `OUTCOME_` / `EVENT_TYPE_` / `FLAG_TYPE_` prefix so
> they match either form.

## Common Actions (`action`)

The recommended response to the flagged user. Values (short form):

| Action | Meaning | Typical Handling |
| --- | --- | --- |
| `PERMIT` | Allow the user through | No action |
| `CHALLENGE` | Step-up verification recommended | Trigger MFA / email or SMS challenge |
| `OFFER` | Present an upsell / offer | Prompt to upgrade (e.g. account sharing → add seats) |
| `DENY` | Block the action | Reject login / purchase |
| `LOG` | Record only | Log for review |

## Common Flags (`flags[].type`)

The reasons a user was flagged. Most-used values (short form):

| Flag | Raised When |
| --- | --- |
| `ACCOUNT_SHARING` | Credentials shared across users/households |
| `ACCOUNT_SHARING_SAME_HOUSEHOLD` | Sharing within one household |
| `MULTIPLE_ACCOUNTS` | One person operating multiple accounts |
| `REPEATED_SIGNUP` | Same person signing up repeatedly |
| `TRIALED_ON_OTHER_ACCOUNT` | Free trial already used on another account |
| `REPEATED_REDEMPTION` | Offer/coupon redeemed repeatedly |
| `SUSPECTED_FRAUD` | General fraud signal |
| `SUSPECTED_BOT` | Automated / bot behaviour |
| `SUSPECTED_ACCOUNT_COMPROMISE` | Possible account takeover |
| `CREDENTIAL_STUFFING` | Credential-stuffing pattern |
| `USING_VPN` / `USING_TOR` | Connecting via VPN / Tor |
| `DISPOSABLE_EMAIL` | Throwaway email address |

See [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/upollo-webhooks/references/overview.md) for the full flag list.

## Important Headers

| Header | Description |
| --- | --- |
| `Upollo-Signature` | `t:<unix_ts>,s0:<hmac-sha512 hex>`. Verify `s0` over the raw body |

## Environment Variables

```bash
# The webhook secret Upollo generated when you added your webhook URL under
# Webhooks on the Access & Keys page (app.upollo.ai).
UPOLLO_WEBHOOK_SECRET=your_webhook_secret_here

```

## Local Development

```bash
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 upollo --path /webhooks/upollo

```

## Testing Flags

Upollo raises flags for specific test emails. Sign up / log in with a suffixed
address to trigger a delivery:

* `you+account_sharing@example.com` → `ACCOUNT_SHARING`
* `you+multiple_accounts@example.com` → `MULTIPLE_ACCOUNTS`

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/upollo-webhooks/references/overview.md) - Upollo webhook concepts, actions, full flag list, payload
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/upollo-webhooks/references/setup.md) - Creating the webhook + secret on the Access & Keys page
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/upollo-webhooks/references/verification.md) - `Upollo-Signature` HMAC-SHA512 details and gotchas

## Operational Status (verify before relying on this skill)

At the time of writing, `app.upollo.ai` / `upollo.ai` did not resolve, and
the npm packages `@upollo/web` and `@upollo/node` return 404 (the PyPI
`upollo-python` client SDK is still published). Upollo may be offline or have
changed hands. Confirm Upollo is operational and re-verify the signature
scheme and payload against a live delivery before depending on this integration.
The verification scheme here is documented and matched to Upollo's own protobuf
definitions but was not confirmed against a live payload.