# Branded Short Links — iec-short

## The Short Version

```typescript
const IEC_SHORT_URL = process.env.IEC_SHORT_URL   // injected — see Setup

const res = await fetch(`${IEC_SHORT_URL}/api/v1/links`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${serviceToken}`,   // your own service JWT — see Auth
  },
  body: JSON.stringify({
    destinationUrl: 'https://policypay.com/portal/pay?loan=L-2024-67890&token=abc',
    source: { service: 'my-service', channel: 'sms', templateName: 'payment-reminder' },
    entity: { iecHash: '0x9f3a2b08721d4c5e8a1b3d7f6e0c2a49b5d81e37', type: 'insured', name: 'Cedar Creek Cattle Co' },
  }),
})
const { shortUrl, slug } = await res.json()
// shortUrl === "https://insre.co/pay-0x9f3a2b08721d4c5e8a1b3d7f6e0c2a49b5d81e37"
```

`iec-short` is the platform's shortlink provider — branded `insre.co` URLs with
click analytics (device, browser, geo) and a hook back into Septor for chain
continuity ("message sent" → "link clicked"). It was built for `iec-relay` (SMS
character limits) but any service can use it for any outbound link.

## Setup

Declare it under `spec.dependencies`. The **Koko-registered name is `iec-short`**
(unlike `iec-relay` → `relay` or `iec-docman` → `docman`, this one keeps its
`iec-` prefix — verified via `tawa services list`):

```yaml
spec:
  dependencies:
    - service: iec-short   # injects IEC_SHORT_URL (+ IEC_SHORT_CHARGE_MODE)
      # transport omitted → defaults to janus (gas-metered, JWT-verified)
```

No `scopes:` needed — `iec-short` does not publish Bio-ID scopes (see Auth below),
so a bare dependency entry is all that's required.

## Auth — bring your own service token

`iec-short`'s routes are `auth: required`, but its `requireAuth` middleware does
**not** check scopes — it accepts *any* Bio-ID JWT that verifies against the JWKS
endpoint (a user token, or any service's own client-credentials token). That
means the calling service just needs its own Bio-ID credentials, which requires
declaring `spec.auth` (either mode gets you `BIO_CLIENT_ID`/`BIO_CLIENT_SECRET`):

```yaml
spec:
  auth:
    mode: service-only   # or 'sso' if you also have user login
```

```typescript
import { BioAuth } from '@insureco/bio'

const bio = BioAuth.fromEnv()
const { access_token: serviceToken } = await bio.getClientCredentialsToken([])

await fetch(`${process.env.IEC_SHORT_URL}/api/v1/links`, {
  headers: { Authorization: `Bearer ${serviceToken}` },
  // ...
})
```

If your service already calls Bio-ID for something else, reuse that token —
there's nothing `iec-short`-specific about it.

## Creating Links

### Single link

```
POST /api/v1/links
```

| Field | Required | Notes |
|-------|----------|-------|
| `destinationUrl` | yes | the real URL to redirect to |
| `source.service` | yes | your service name — shows up in `GET /api/v1/links?service=` |
| `source.messageId` / `source.channel` / `source.templateName` | no | free-form context, useful for correlating back to `iec-relay` sends |
| `slug` | no | custom slug (3–60 chars, `[a-z0-9-]`); omit to auto-generate |
| `entity.iecHash` / `entity.type` / `entity.name` | no | InsureBio entity link. `iecHash` must be `0x` + 40 lowercase hex (iec-short rejects anything else with 400, e.g. a slug or a Mongo ObjectId); omit it if your record has none. When present with an inferred `action` (pay/quote/bind/cancel/renew/sign/doc), the slug auto-derives as `{action}-{iecHash}` instead of a random one |
| `septorChainId` | no | ties click events back into an existing Septor chain (e.g. the `messageId` of the relay send that generated this link) |
| `expiresAt` / `maxClicks` | no | link auto-deactivates (`410 Gone`) past either limit |
| `tags` | no | array of strings, filterable later |

```json
// 201 response
{
  "id": "...",
  "shortUrl": "https://insre.co/pay-0x9f3a2b08721d4c5e8a1b3d7f6e0c2a49b5d81e37",
  "slug": "pay-0x9f3a2b08721d4c5e8a1b3d7f6e0c2a49b5d81e37",
  "slugType": "auto",
  "destinationUrl": "...",
  "expiresAt": null,
  "createdAt": "2026-09-14T..."
}
```

### Bulk create

```
POST /api/v1/links/bulk
```

Same shape, `{ "links": [ {...}, {...} ] }`, max **100** per request. Per-item
failures don't fail the batch — response is `{ created, failed, links, errors }`
with `errors[].index` pointing back into your input array.

## Querying & Managing Links

```
GET   /api/v1/links?iecHash=&service=&messageId=&tag=&isActive=&limit=&offset=
GET   /api/v1/links/:id
PATCH /api/v1/links/:id   { destinationUrl?, isActive?, expiresAt?, maxClicks?, tags? }
```

`PATCH` invalidates the Redis redirect cache for that slug immediately — a
deactivated or redirected link takes effect on the very next click, not after a
TTL.

## Click Analytics

```
GET /api/v1/stats/:linkId     # single link — device/browser/geo breakdown + daily timeline
GET /api/v1/stats?slug=|iecHash=|service=&days=30   # aggregate across a filter
```

`stats/:linkId` returns `devices`, `browsers`, `topLocations` (top 10 by click
count), and `clickTimeline` (daily counts) alongside `totalClicks` /
`uniqueClicks`. Uniqueness is tracked by a first-party `iec_short_cid` cookie on
the redirect hop, not by IP — a user without cookies enabled counts as unique on
every click.

## The Redirect Itself

`GET https://insre.co/:slug` — this is what actually fires when someone clicks
the link. No auth, 302 to `destinationUrl`. It's cache-first (Redis, with a
MongoDB fallback and re-cache on miss), so the extra hop is fast even under
load. Click tracking (`trackClick`) runs `setImmediate`-deferred and fire-and-
forget — a tracking failure never delays or breaks the redirect.

Failure responses on the redirect itself:

| Status | Cause |
|--------|-------|
| `404` | slug not found, or a reserved path (`api*`, `admin`, `favicon.ico`, `robots.txt`) |
| `410` | link deactivated, expired, or hit `maxClicks` |

## What NOT to Do

```typescript
// ❌ WRONG: hardcoding insre.co or a K8s DNS URL
await fetch('https://insre.co/api/v1/links', ...)
await fetch('http://iec-short.iec-short-prod.svc.cluster.local:3000/api/v1/links', ...)

// ✅ CORRECT: use the injected dependency URL
await fetch(`${process.env.IEC_SHORT_URL}/api/v1/links`, ...)

// ❌ WRONG: assuming a scope grant gates this — it doesn't
spec:
  dependencies:
    - service: iec-short
      scopes: [short:create]   # inert — iec-short publishes no scopes; harmless but does nothing

// ❌ WRONG: awaiting stats/creation on the hot path of an SMS/email send
await relay.sendEmail({ html: `<a href="${await shorten(url)}">Pay now</a>` })
// If iec-short is slow, your send is slow too — shorten opportunistically/async
// where the flow allows it, and fall back to the full URL on error.
```

## Key Facts

- **Koko name is `iec-short`, not `short`** — this one didn't get the prefix
  stripped like `relay`/`docman` did. Double-check with `tawa services list` if
  unsure before assuming a pattern.
- **No Bio-ID scopes published** — any verified Bio-ID token (yours or a user's)
  is accepted. There's no per-caller allow-list today; don't rely on `iec-short`
  itself to gate *who* can create links pointing anywhere.
- **No SDK package yet** — this doc is the only interface contract; calls are
  plain `fetch`. If you build a wrapper, consider publishing `@insureco/short`
  so this convention can be updated to reference it.
- Redirect responses are always `302`, never `301` — links are meant to be
  re-pointable (`PATCH destinationUrl`) without browsers caching the redirect.
- `slug` auto-generation prefers `{action}-{iecHash}` (e.g.
  `pay-0x9f3a2b08721d4c5e8a1b3d7f6e0c2a49b5d81e37`) over random, and falls back to an 8-char random slug
  only on conflict or when no `entity.iecHash` is given.
- Uniqueness for `uniqueClickCount` is cookie-based (`iec_short_cid`), not
  IP-based — treat it as directional, not exact.
- `databases: [mongodb, redis]` — MongoDB is the record of truth, Redis is a
  redirect-speed cache invalidated on every `PATCH`.
