결제 위젯

결제 위젯

앱이 Stripe 양식을 직접 보여줄 수 없을 때, 결제하거나 카드를 추가할 수 있는 안전한 Atli 페이지로 사용자를 보내세요.

언제 쓰나요

  • AI 어시스턴트와 MCP 클라이언트: 채팅으로 사람과 대화하며, 카드 양식을 둘 자체 화면이 없습니다.

  • 흐름 밖에서 결제: 예를 들어 Telegram이나 WhatsApp의 봇, 또는 다른 기기에서 하거나 나중에 마무리하는 경우입니다.

  • Stripe SDK를 불러올 수 없는 그 밖의 클라이언트: 예를 들어 터미널이나 음성 앱입니다.

PCI DSS
카드와 Apple Pay를 선택할 수 있는 결제 페이지 양식

setup_intent

사용자는 지금 결제하지 않고 카드를 계정에 저장하며, 이후 클라이언트가 그 카드로 구매할 수 있습니다.

  1. 카드 추가 시작하기

    POST /user/payment-methods/setup-intent는 client_secret 옆에 세 개의 필드를 함께 응답합니다.

    그룹의 카드에는 같은 방식으로 POST /groups/:groupId/cards/setup과 POST /groups/:groupId/cards/confirm을 사용하세요. 이들의 응답에서는 ID를 setup_intent_id라고 부릅니다.

    curl -X POST https://api.heyatli.com/user/payment-methods/setup-intent \
      -H 'Authorization: Bearer YOUR_TOKEN'
    
    {
      "id": "seti_…",
      "client_secret": "seti_…_secret_…",
      "sealed_secret": "eyJhbGciOiJFQ0RILUVTIi…",
      "key_id": "QvWE2gHWYExKj2Yc…",
      "seal_signature": "c2lnbmF0dXJl…",
      "options": "{\"lang\":\"en\",\"message\":\"Plan for Australia\"}",
      "options_signature": "b3B0aW9ucw…"
    }
  2. 상대에게 링크 보내기

    세 값을 결제 페이지 링크에 넣고 type을 setup_intent로 설정하세요. 사용자에게는 금액 없이 Stripe 양식만 보입니다.

    const link = new URL('https://heyatli.com/pay');
    link.search = new URLSearchParams({
      type: 'setup_intent',
      sealed_secret: response.sealed_secret,
      key_id: response.key_id,
      seal_signature: response.seal_signature,
      return_id: response.id,
      // Only when the response has them, and always together. options goes in
      // base64url-encoded (Buffer in Node; TextEncoder and btoa in a browser).
      ...(response.options && {
        options: Buffer.from(response.options).toString('base64url'),
        options_signature: response.options_signature,
      }),
    }).toString();
  3. 카드 저장하기

    사용자가 마치면 POST /user/payment-methods/confirm이 카드를 계정에 저장합니다. 그 뒤 GET /user/payment-methods로 목록을 볼 수 있습니다.

    curl https://api.heyatli.com/user/payment-methods/confirm \
      -H 'Authorization: Bearer YOUR_TOKEN' \
      -H 'Content-Type: application/json' \
      -d '{ "setup_intent_id": "seti_…" }'

링크에 들어가는 것

매개변수필수역할
type예

payment_intent 또는 setup_intent. 봉인된 시크릿의 종류와 일치해야 합니다.

sealed_secret예

응답에서 받은 값을 받은 그대로 전달합니다.

key_id예

응답에서 받은 값입니다. 시크릿을 봉인한 키를 가리킵니다.

seal_signature예

응답에서 받은 값입니다. 페이지가 이 값을 확인하고, 없거나 일치하지 않는 링크는 거부합니다.

options아니요

응답의 options를 base64url로 인코딩한 값입니다. 지금은 페이지가 양식 위에 보여 주는 메시지가 요청한 언어로 들어 있습니다. API에 보여 줄 것이 있을 때만 옵니다.

options_signature아니요

응답의 값을 바꾸지 않고 options와 함께 전달합니다. 일치하지 않으면 페이지는 옵션을 빼고 평소처럼 동작합니다.

lang아니요

페이지 언어로, 이 사이트의 15개 언어 중 하나입니다. 없으면 그 사람이 heyatli.com에서 고른 언어, 그다음 브라우저 언어를 씁니다.

theme아니요

dark 또는 light. 기본값은 브라우저 설정입니다.

brand아니요

none은 Atli 로고와 라벨을 숨깁니다. 기본값은 표시입니다.

redirect_success_url아니요

이후 사용자를 보낼 곳입니다. heyatli.com 또는 그 하위 도메인의 https 주소여야 합니다.

redirect_cancel_url아니요

이후 사용자를 보낼 곳입니다. heyatli.com 또는 그 하위 도메인의 https 주소여야 합니다.

redirect_failure_url아니요

이후 사용자를 보낼 곳입니다. heyatli.com 또는 그 하위 도메인의 https 주소여야 합니다.

return_id아니요

리디렉션에 덧붙일 사용자만의 참조 값입니다. 영문자, 숫자, _, -를 쓸 수 있고 최대 128자입니다.

링크 다루는 법

  • 값을 바꾸지 말고 그대로 전달하세요. 인코딩은 URLSearchParams가 처리합니다.

  • 페이지 옵션도 전달하세요. 응답에 options와 options_signature가 있으면 둘 다 추가하세요. options는 base64url로 인코딩하고, options_signature는 받은 그대로입니다. 인코딩하기 전에 JSON을 바꾸지 마세요.

  • 한 환경 안에서만 사용하세요. Sandbox 값은 staging.heyatli.com/pay에서만, Production 값은 heyatli.com/pay에서만 열립니다.

  • 비밀번호처럼 다루세요. 계정 소유자에게만 보내고 로그에는 남기지 마세요.

  • 주문은 한 번에 하나입니다. 새로 구매하면 대기 중인 이전 주문과 그 링크가 취소되므로 링크를 저장하지 마세요.

  • 세 필드가 없으면 다시 호출하세요. 암호화되지 않은 client_secret을 링크에 넣으면 안 됩니다.

  • 리디렉션은 아무것도 증명하지 않습니다. 마지막 단계의 확인만 믿으세요.

암호화와 서명의 작동 방식

링크를 보내는 데는 이 내용이 필요하지 않습니다. 어떤 일이 일어나는지 보고 서버가 반환한 내용을 확인할 수 있도록 적어 둔 것입니다. 둘 다 공개 키를 사용하며, 선택한 환경의 주소는 다음과 같습니다.

결제 페이지
https://heyatli.com/pay

암호화

sealed_secret은 client_secret을 간결한 JWE로 만든 것으로, URL에 넣어도 안전한 형식입니다. 사이트의 X25519 공개 키로 암호화되므로 사이트의 서버만 읽을 수 있습니다. key_id는 그 키의 id이며 JWE의 kid와 일치합니다.

alg
ECDH-ES
enc
A256GCM
kid
key_id
Key
JWK: kty OKP, crv X25519, x = public_key

API가 대신 처리하며, 목록의 첫 번째 키를 사용합니다. 코드로는 다음과 같습니다.

import { CompactEncrypt, importJWK } from 'jose';

const { keys: [key] } = await (await fetch('https://heyatli.com/api/keys')).json();

const publicKey = await importJWK(
  { kty: 'OKP', crv: 'X25519', x: key.public_key },
  'ECDH-ES'
);
const sealedSecret = await new CompactEncrypt(new TextEncoder().encode(clientSecret))
  .setProtectedHeader({ alg: 'ECDH-ES', enc: 'A256GCM', kid: key.id })
  .encrypt(publicKey);

서명

누구나 암호화할 수 있으므로, 봉인된 값만으로는 누가 만들었는지 알 수 없습니다. 그래서 API는 자신만 가진 Ed25519 개인 키로 서명합니다. 서명 대상 메시지는 아래 세 줄을 줄바꿈으로 이은 UTF-8 바이트이며, 끝에는 줄바꿈이 없습니다.

atli-seal-v1
<key_id>
<sealed_secret>

API는 외부로 나가지 않는 개인 키로 다음과 같이 서명을 만듭니다.

// What the API does. Only it holds the private key.
const message = new TextEncoder().encode(
  `atli-seal-v1\n${keyId}\n${sealedSecret}`
);

const signature = await crypto.subtle.sign('Ed25519', privateKey, message);
const sealSignature = Buffer.from(signature).toString('base64url');

seal_signature는 64바이트 원시 서명을 패딩 없는 base64url로 나타낸 것으로, 86자입니다. 환경마다 자체 키로 서명하며, 키를 교체하는 동안에는 목록에 두 개가 있을 수 있습니다.

결제 페이지는 무엇이든 열기 전에 이것을 확인하고, 없거나 일치하지 않는 링크는 거부합니다. 서버가 반환한 내용을 직접 확인하려면 그 메시지를 다시 만들고 서명 키로 서명을 검증하세요.

// In Node. sealedSecret, keyId and sealSignature come from the API response.
const { keys } = await (await fetch('https://api.heyatli.com/keys')).json();

const base64url = (value) => Uint8Array.from(Buffer.from(value, 'base64url'));
const message = new TextEncoder().encode(
  `atli-seal-v1\n${keyId}\n${sealedSecret}`
);

// The list can hold two keys during a rotation: one match is enough.
const checks = keys.map(async (key) =>
  crypto.subtle.verify(
    'Ed25519',
    await crypto.subtle.importKey('raw', base64url(key.public_key), 'Ed25519', false, ['verify']),
    base64url(sealSignature),
    message
  )
);
const valid = (await Promise.all(checks)).some(Boolean);

sealed_secret이나 key_id를 바꾸면 서명이 깨집니다. 서명은 type, 리디렉션, 나머지 매개변수를 다루지 않으며, 링크를 연 사람이 계정의 주인임을 보여 주지도 않습니다. 서명된 링크는 Stripe가 시크릿을 받아 주는 동안 계속 작동합니다.

페이지 옵션

API는 options를 JSON 텍스트로 반환합니다. 최대 300자의 일반 텍스트인 message와 메시지의 언어인 lang이 들어 있습니다. 키가 더 생길 수 있으며, 페이지는 모르는 키를 무시합니다. 메시지는 API 전체와 마찬가지로 요청 언어를 따릅니다: X-Locale, Accept-Language 또는 ?lang=. 링크에는 base64url로 들어가므로 이스케이프가 필요 없고, 어떤 언어로든 짧게 유지됩니다. 옵션은 부가 기능입니다. 없거나 검증을 통과하지 못해도 페이지는 옵션 없이 결제를 받습니다.

// response.options, as the API returns it
{"lang":"en","message":"Plan for Australia"}

options_signature는 같은 키로 다음 네 줄에 대해 만들어지며, <options>는 API가 반환한 그대로의 JSON입니다. 페이지는 링크의 값을 디코딩해 이를 확인합니다. 봉인된 시크릿이 포함되므로, 다른 링크로 옮긴 옵션은 통과하지 못합니다:

atli-options-v1
<key_id>
<sealed_secret>
<options>