Dynamo Developer DocsDynamo Developer Docs

Link Protection

Verify Dynamo-signed links on your backend — signature, replay, expiry, and user binding — before granting a reward.

When Dynamo sends a link to a user — for example a reward link in a broadcast — anyone who obtains the URL could try to open it: the original recipient, but also someone it was forwarded to, or someone who fabricated it outright. Link Protection lets your backend tell the difference: Dynamo signs every protected link, and you verify the signature — plus replay, expiry, and user binding — before granting anything.

Everything on this page runs on your backend. Dynamo signs the link when it's clicked; verification — and the policy around it, like how long a token stays valid — is yours.

How it works

Dynamo sends the link. The message the user receives contains a protected link with its unique link id.

The user clicks — Dynamo signs. At click time, Dynamo mints the signed token — the link, its query params, the link id, the recipient's user id when known, and the click's timestamp — and the user lands on your site with it attached. Every click gets a fresh token; the d10xLinkId stays the same.

Your backend verifies. Run the seven checks below. Any failure means the link is invalid — don't grant.

Grant — and consume. On success, grant the reward and consume the link id — for good. If you use the Audience API and the token carried no cuid, identify the user so future links arrive already bound to them.

When a user clicks a protected link, they arrive at your site with two query parameters attached:

https://play.example.com/rewards?promo=spring50&d10x_link_id=a1b2c3&d10xlt=eyJhbGciOiJIUzI1NiIs...
ParameterWhat it is
d10x_link_idA unique id for the link — the same across every click, and the basis for single-use enforcement.
d10xltThe link token: a JWT signed with HMAC-SHA256 (HS256), keyed by your organization's API secretminted fresh on every click.

The token is signed with the same secret you use to sign API requests — there is no separate Link Protection secret. It must live server-side only, never in a browser or app bundle.

The token

Verifying d10xlt (with HS256 pinned) yields this payload:

Decoded d10xlt payload
{
  "d10xLinkId": "a1b2c3",
  "cuid": "user_123",
  "originalLink": "https://play.example.com/rewards",
  "originalQueryParams": [{ "k": "promo", "v": "spring50" }],
  "iat": 1754745600
}
FieldTypeMeaning
d10xLinkIdstringUnique id for the link, identical across clicks. Must match the d10x_link_id query param.
cuidstring?Your user id for the recipient, when Dynamo already knows it (learned from a previous identify). Absent otherwise.
originalLinkstringThe link the token was issued for, query params stripped.
originalQueryParams{ k, v }[]The link's original query params at issuance.
iatnumberIssued-at for this click, in seconds since epoch — each click carries a fresh iat.

There is no exp claimjwt.verify only proves authenticity. Expiry is your policy: enforce your own TTL from iat (step 5 below).

Run these checks in order; any failure means the link is invalid. Steps 1–4 verify what Dynamo signed; steps 5–7 apply your policy.

#CheckBlocks
1The d10xlt JWT verifies with your API secret, algorithm pinned to HS256.Forged links
2The d10x_link_id query param equals the token's d10xLinkId.Token swapping
3The landing URL, query stripped, equals originalLink.Replaying a token on a different URL
4Every originalQueryParams entry is still present with the same value.Tampered params
5now − iat is within your TTL.Stale links
6The d10xLinkId was never consumed before (atomic check-and-set).Replay & link sharing
7The token's cuid, when present, matches the logged-in user.Granting to the wrong account

A few details worth knowing:

  • URL matching (step 3). originalLink is the normalized URL form — compare against new URL(link) with search cleared and .toString(), as in the reference below, rather than raw string manipulation.
  • Param matching (step 4). The baseline check is one-directional: params recorded in the token must survive unchanged, while extra appended params (e.g. UTM tags) are tolerated. You can be stricter if your links should never gain params.
  • TTL (step 5). Dynamo provides only iat, stamped at click time — the TTL only needs to cover the hop from the click to your backend, so keep it short. The reference uses 5 minutes.
  • Single use (step 6). Consume the link id atomically (a conditional insert / check-and-set) — a read followed by a separate write lets two concurrent clicks both pass. Retain consumed ids forever: every click mints a fresh, unexpired token for the same d10xLinkId, so expiry alone never makes a consumed id safe to forget.

When the token has no cuid

A missing cuid means Dynamo doesn't yet know which of your users this recipient is. Grant as usual — verification doesn't depend on it, and step 7 simply has nothing to check.

If your organization uses the Audience API, this is also the moment to report the mapping with an identify call, passing the link id from the token and your id for the logged-in user:

POST /v1.0/user/identify
{ "d10xLinkId": "a1b2c3", "userId": "user_123" }

Once identified, future protected links for that user carry their cuid, and step 7 binds every link to the account it was sent to. If you don't use the Audience API, skip this section.

Reference implementation

verify-link.ts (Node.js)
import * as jwt from 'jsonwebtoken'

export interface D10xLTPayload {
  d10xLinkId: string
  cuid?: string // your user id, when Dynamo already knows the mapping
  originalLink: string // the link the token was issued for, query stripped
  originalQueryParams: { k: string; v: string }[]
  iat: number // issued at, seconds since epoch
}

export enum ED10xLTQueryParams {
  D10X_LINK_TOKEN = 'd10xlt',
  D10X_LINK_ID = 'd10x_link_id',
}

const TTL_SECONDS = 5 * 60 // your policy — how long a link stays valid

export async function isValidLink(link: string): Promise<boolean> {
  const url = new URL(link)

  // 1. Verify the signature (HS256 pinned)
  const token = url.searchParams.get(ED10xLTQueryParams.D10X_LINK_TOKEN)
  if (!token) return false
  const payload = decodePayload(token, await getDynamoSecret())
  if (!payload) return false

  // 2. The d10x_link_id param must match the token
  if (url.searchParams.get(ED10xLTQueryParams.D10X_LINK_ID) !== payload.d10xLinkId) {
    return false
  }

  // 3. The URL, query stripped, must be the one the token was issued for
  const params = [...url.searchParams].map(([k, v]) => ({ k, v }))
  url.search = ''
  if (url.toString() !== payload.originalLink) return false

  // 4. Every original query param must still be present, unchanged
  //    (extra params — e.g. UTM tags — are tolerated; tighten if you need to)
  for (const p of payload.originalQueryParams) {
    if (!params.some((q) => q.k === p.k && q.v === p.v)) return false
  }

  // 5. Enforce your TTL from iat
  const now = Math.floor(Date.now() / 1000)
  if (now - payload.iat > TTL_SECONDS) return false

  // 6. Single use — atomically consume the link id
  if (!(await consumeD10xLinkId(payload.d10xLinkId))) return false

  // 7. Bind to the user
  const userId = await getLoggedInUserId()
  if (payload.cuid) {
    if (payload.cuid !== userId) return false
  } else {
    // Optional — only if you use the Audience API: report the mapping
    // so future links carry the cuid
    await sendIdentify(payload.d10xLinkId, userId)
  }

  return true // the link is valid — grant the reward
}

function decodePayload(token: string, secret: string): D10xLTPayload | undefined {
  try {
    return jwt.verify(token, secret, { algorithms: ['HS256'] }) as D10xLTPayload
  } catch {
    return undefined // invalid or forged token
  }
}

// ——— Implement these for your platform ———

/** Your Dynamo API secret, from your secret manager. Server-side only. */
async function getDynamoSecret(): Promise<string> {
  throw new Error('TODO: load your Dynamo API secret')
}

/** Atomically mark the link id used — and keep it forever. Return false if it
 *  was already consumed. */
async function consumeD10xLinkId(d10xLinkId: string): Promise<boolean> {
  throw new Error('TODO: conditional insert into your used-links store')
}

/** The user viewing the link, by the id you identify them to Dynamo with. */
async function getLoggedInUserId(): Promise<string> {
  throw new Error('TODO: resolve the logged-in user')
}

/** Optional (Audience API users): POST /v1.0/user/identify
 *  with { d10xLinkId, userId }. */
async function sendIdentify(d10xLinkId: string, userId: string): Promise<void> {
  throw new Error('TODO: call the Audience API identify endpoint')
}

Security checklist

  • Verify server-side only. The secret signs both your API requests and these tokens — it must never reach a browser or mobile app.
  • Pin the algorithm to HS256 when calling jwt.verify.
  • Consume link ids atomically — and retain them forever. Every click mints a fresh, unexpired token for the same id, so expiry never makes a consumed id safe to forget.
  • Keep the TTL short. A protected link is meant to be clicked once, right away.
  • Fail closed. Any check that can't be evaluated is a failed check.

Next steps

On this page