01.software Docs

Customers SDK

Customer identity, hosted sessions and source-aware groups.

For a complete task, start with the Customers guide. This page covers the public Customers SDK; see SDK basics for imports and peers.

Source-aware customer groups

Trusted server workflows can list the reasons a Customer belongs to a Customer Group, and add or revoke only the manual source:

import { createServerClient } from '@01.software/sdk/server'

type Server = ReturnType<typeof createServerClient>

export async function grantManualMembership(
  server: Server,
  input: { groupId: string; customerId: string; reason: string },
) {
  await server.customerGroups.grantMember(input)
  return server.customerGroups.listMembers({
    groupId: input.groupId,
    limit: 100,
  })
}

export async function revokeManualMembership(
  server: Server,
  input: { groupId: string; customerId: string },
) {
  await server.customerGroups.revokeMember(input)
  return server.customerGroups.listMembers({
    groupId: input.groupId,
    limit: 100,
  })
}

Call grantManualMembership to keep the manual source. Call revokeManualMembership separately, only after you decide to remove that source; granting never revokes it. An active Membership source still applies after the manual source is revoked. Treat customers.groups as read-only compatibility data: once source-based membership is active, do not write it directly.

Customer Auth

Hosted customer OAuth is the default for new storefronts. Configure the browser-safe client with the workspace Publishable Key, the customer auth issuer, and the client id:

import { createClient } from '@01.software/sdk'

// Consume this client in trusted routes with a server-owned token store.
export function createHostedCustomerClient(
  publishableKey: string,
  issuer: string,
  clientId: string,
) {
  return createClient({
    publishableKey,
    customer: { persist: false, oauth: { issuer, clientId } },
  })
}

In a trusted route handler, first check that client.customer.oauth is configured, then call createAuthorizationUrl() to start PKCE. Exchange the callback code on the server, and keep the access token, refresh token, and temporary OAuth state in HttpOnly cookies or another server-owned session store. Never put hosted customer OAuth tokens in browser JavaScript, localStorage, or client-readable cookies.

client.customer.auth.login() and register() remain for legacy direct email and password sign-in; new ecommerce templates should expose them only behind explicit legacy routes.

  • await client.customer.auth.logoutAsync() revokes a direct session's refresh-token family. It clears local credentials even if revocation fails; treat a rejected promise as an incomplete server logout.
  • The older synchronous logout() clears local state only.
  • useCustomerLogout() waits for revocation and clears authenticated customer query data either way.
  • An access token that was already issued can stay valid for up to 15 minutes.

Workspace admins can turn off new password registrations in Console Customer Authentication settings. register() then returns 403 registration_disabled; existing password login and reset still work.

Complete an existing local-password reset

This flow is for an existing app and a Customer who already has a local password, including a linked social account that has one. It does not change an external identity provider's password or add a password to a passwordless account. No signed-in session, Secret Key, PAT, or customer ID is needed: use the workspace's Publishable Key and the reset token sent to the user.

In Console Customer Authentication, choose platform delivery and set an absolute HTTPS storefront reset URL. Platform email appends the token query parameter and uses the verified workspace sender when available. The SDK's forgotPassword(email) takes only the email, with no redirect argument. After it resolves, show a generic receipt: it tells you neither whether the account exists, whether the mail arrived, nor whether the password was reset. Platform and webhook delivery are alternatives; choosing platform does not also make your worker send the email.

These environments limit delivery. Do not read stored reset tokens to get around this.

  • A Sandbox workspace sends platform email only to the own address of a member of that Sandbox. The member must have verified their current email; the mail uses the platform sender and a [Sandbox] subject. Mail to any other address and reset webhooks are only captured.
  • A tenantPurpose: test workspace is denied external effects: neither mode delivers externally, and webhook endpoint lookup skips it.

The complete code below uses the app's existing URL and cleanup adapters.

  • replaceUrl must remove the token from the URL immediately, for example with a browser history replacement before other scripts load.
  • Keep the returned token only in the reset form's memory until submission, never in browser storage. If you move it into existing trusted server temporary state instead, bind that state and the validated form submission to the app's trusted session. No new session engine is needed.
  • clearLocalSession clears app-owned customer cookies and cache with the existing app adapter; it does not revoke server sessions.
import { createClient } from '@01.software/sdk'
import {
  getSDKErrorReason,
  isNetworkError,
  isSDKError,
  isTimeoutError,
} from '@01.software/sdk/errors'

type ResetClient = ReturnType<typeof createClient>
type ResetFailure = {
  kind: 'validation_failed' | 'invalid_token' | 'unconfirmed' | 'unavailable'
}

export type ResetPagePort = {
  // Remove the token from the visible URL before analytics or other scripts run.
  replaceUrl(cleanUrl: string): void
  // Clear app-owned customer cookies/cache; this does not attest server revocation.
  clearLocalSession(): Promise<void>
}

export function createPasswordResetClient(
  publishableKey: string,
  apiUrl?: string,
) {
  return createClient({ publishableKey, apiUrl, customer: { persist: false } })
}

export function takePasswordResetToken(
  currentUrl: string,
  page: ResetPagePort,
) {
  const url = new URL(currentUrl)
  const tokens = url.searchParams.getAll('token')
  url.searchParams.delete('token')
  page.replaceUrl(url.toString())
  return tokens.length === 1 ? tokens[0] : null
}

function resetFailure(error: unknown): ResetFailure {
  if (isNetworkError(error) || isTimeoutError(error)) {
    return { kind: 'unconfirmed' }
  }
  if (isSDKError(error)) {
    const reason = getSDKErrorReason(error)
    if (reason === 'validation_failed' || reason === 'invalid_token') {
      return { kind: reason }
    }
    if (
      error.status === undefined ||
      error.status < 400 ||
      error.status >= 500
    ) {
      return { kind: 'unconfirmed' }
    }
    return { kind: 'unavailable' }
  }
  return { kind: 'unconfirmed' }
}

export async function requestPasswordReset(client: ResetClient, email: string) {
  try {
    await client.customer.auth.forgotPassword(email)
    return { kind: 'accepted' } as const
  } catch (error) {
    return resetFailure(error)
  }
}

export async function submitPasswordReset(
  client: ResetClient,
  token: string,
  password: string,
  page: ResetPagePort,
) {
  let recovery: Awaited<
    ReturnType<ResetClient['customer']['auth']['resetPassword']>
  >
  try {
    // Preserve exactly what the user entered, including leading/trailing spaces.
    recovery = await client.customer.auth.resetPassword(token, password)
  } catch (error) {
    return resetFailure(error)
  }
  try {
    await page.clearLocalSession()
  } catch {
    return { kind: 'password_reset_cleanup_required' } as const
  }
  return { kind: 'password_reset', ...recovery } as const
}

Wire the form in this order:

  1. Create a fresh createPasswordResetClient with the workspace Publishable Key. Submit the email to requestPasswordReset, and show the generic receipt only for accepted.
  2. On the configured reset page, call takePasswordResetToken once, before rendering other integrations. A missing, empty, or duplicate token needs a new reset request. Collect the new password in the existing form.
  3. Call submitPasswordReset with that token and the exact password entered. Do not trim or normalize the password. Disable repeat submission while pending, and discard form secrets when the flow ends.
  4. Only password_reset means the reset and the app cleanup both completed; offer the existing login entry on the clean URL. password_reset_cleanup_required means the password changed but app cleanup needs recovery; finish that cleanup before continuing. Neither outcome signs the customer in or issues a new session or JWT.

Protect the first request as well as the cleaned page: redact token from proxy, server, access, and error logs, set Referrer-Policy: no-referrer and Cache-Control: no-store, and keep analytics, tracking redirects, and third-party scripts out of this flow. Cleaning the URL cannot undo a log of the first request. Never log or render token or password values or raw SDK errors. For the app's existing sign-in entry, follow Existing apps: direct legacy compatibility; do not duplicate its session code.

ResultMeaningWhat to show
validation_failedEmpty or oversized token, or invalid password format or length (currently token 1–128 characters, password 8–128).Password: ask for a valid password. Token: offer a new reset request.
invalid_tokenA wrong, expired, or already used token; the API does not tell these apart.One "link no longer works" message and a new reset request. Do not add a validity lookup, separate expired or used reasons, or account enumeration.
unavailableOther explicit SDK rejections, including feature or workspace denial.An error. Never show success.
unconfirmedNetwork or timeout, a server or unreadable-response error, or an unknown failure.The password may already have changed and the token may be used. Offer the existing login, a new reset request, or account support.

Auth calls make no automatic retries; do not loop or resubmit blindly. A successful reset records the password change, but server session revocation may not have finished yet. Local cleanup and server revocation are separate.

After reset and local cleanup, keep recoveryLoginPermit only in memory for the next explicit login. Send it with the email and exact new password: client.customer.auth.login({ email, password, recoveryLoginPermit }). It expires after five minutes and is consumed by one verification attempt, including a wrong password. It bypasses the customer target bucket and password restriction; workspace limits, suspension and session storage checks still apply. Request a new reset after expiry or a failed attempt. Each unused reset link keeps its original expiry across reissue; a successful reset invalidates all other links. Older servers may omit recovery fields.

Optional signed webhook worker

Choose webhook delivery only after reviewing every active receiver in the workspace. Password reset is a system event: the raw reset token, email, and name go to all active webhook endpoints regardless of their subscriptions, and a subscription filter does not narrow that. Keep only trusted receivers; other receivers should ignore this event as unhandled and never store or log its token, email, name, or the whole event.

Use the existing signed receiver and endpoint setup. The worker below is complete code for a trusted endpoint. Its configuredResetUrl is app-owned configuration for the same protected HTTPS reset page, never a redirect taken from the event. Pass the endpoint's own signing secret and the original, unread Request: verification covers the exact raw bytes before the CustomerAuth factory checks the password-reset shape.

import {
  createCustomerAuthWebhookHandler,
  handleSignedWebhook,
} from '@01.software/sdk/webhook'

export type ResetDeliveryPort = {
  // Atomically dedupe by deliveryId in this endpoint's namespace using the
  // existing mail adapter. Resolve only after it accepts responsibility.
  // Never log these fields or persist the token/link in a general event log.
  sendOnce(input: {
    deliveryId: string
    to: string
    name: string
    resetUrl: string
    expiresAt: string
  }): Promise<void>
}

export function receivePasswordReset(
  request: Request,
  secret: string,
  configuredResetUrl: string,
  delivery: ResetDeliveryPort,
) {
  const resetUrl = new URL(configuredResetUrl)
  if (
    resetUrl.protocol !== 'https:' ||
    resetUrl.username ||
    resetUrl.password ||
    resetUrl.hash
  ) {
    throw new Error('Configure a trusted HTTPS reset page')
  }
  return handleSignedWebhook(
    request,
    createCustomerAuthWebhookHandler({
      // The SDK factory calls this only after its password-reset shape guard.
      passwordReset: async (data, event) => {
        const expiry = Date.parse(data.resetPasswordExpiresAt)
        if (!Number.isFinite(expiry) || expiry <= Date.now()) return
        const deliveryId = request.headers.get('x-webhook-delivery-id')
        if (!deliveryId || event.deliveryId !== deliveryId) {
          throw new Error('Reset delivery identity is missing or inconsistent')
        }
        resetUrl.searchParams.set('token', data.resetPasswordToken)
        try {
          await delivery.sendOnce({
            deliveryId,
            to: data.email,
            name: data.name,
            resetUrl: resetUrl.toString(),
            expiresAt: data.resetPasswordExpiresAt,
          })
        } catch {
          // The SDK receiver logs thrown errors: never propagate provider PII.
          throw new Error('Password reset delivery unavailable')
        }
      },
      // Other receivers must ignore this event without storing its raw data.
      unhandled: () => {},
    }),
    { secret, toleranceSeconds: 300 },
  )
}

Connect sendOnce to the app's existing mail adapter.

  • It must handle concurrent duplicates by the stable delivery ID in this endpoint's namespace, and never send again after an accepted attempt.
  • Keep only the minimal dedupe identity and outcome, not a copy of the event or reset link; transient mail payloads follow the app's existing protected retention.
  • Resolve only when the adapter accepts responsibility. If the provider outcome is uncertain, reconcile that same delivery identity instead of sending again blindly.
  • The catch removes provider error details before the SDK receiver can log them. Also configure request and SDK error logging to redact bodies, credentials, and personal data.

A successful receiver response or provider acceptance is not proof that the mail reached the inbox.

Signature tolerance (300 seconds here) checks how fresh the delivery timestamp is, resetPasswordExpiresAt checks token expiry, and deliveryId is the key sendOnce uses to drop duplicates. These are separate checks: a fresh signature does not extend an expired token. This example ignores events that are expired or have an invalid expiry, and unrelated or malformed password-reset shapes, without sending mail. Missing or invalid signature metadata is rejected with 401; there is no unsigned fallback.

Reset delivery is separate from ordinary collection webhooks: their outbox retention, redelivery, and signing guarantees do not apply to it. Platform email logs are not a token store, permanent personal-data storage, proof of mail arrival, or permission to resend. Keep the existing email outcome, retention, and customer-erasure rules.

Self-withdrawal

A signed-in customer with a local password calls client.customer.auth.withdraw(currentPassword), which returns { withdrawnAt }. Success archives the account immediately and clears the SDK credentials. Also clear application-owned session cookies and customer caches. The archived account cannot use its existing sessions or sign in again.

Only the signed-in customer can withdraw; a Secret Key cannot withdraw on a customer's behalf. Withdrawal erases the account's personal data right away and cannot be undone. Orders, payments and membership charges are kept. After erasure the same email can sign up again as a new customer. See Customer self-withdrawal.

SDKError.apiReasonStatusWhat to show
active_membership_exists409End the membership first. A scheduled cancellation still blocks withdrawal until its status ends.
reauthentication_unavailable409No local password is set: request a withdrawal email code.
password_reauthentication_required409This account has a local password: use it to confirm withdrawal.
withdrawal_session_refresh_required409Refresh this existing session before requesting a code.
account_locked423Too many wrong passwords; this shares the sign-in attempt counter. Try again after the temporary lock ends.

When withdrawal is refused, the SDK keeps the customer signed in.

Accounts without local password material, including social-only accounts, can request a withdrawal-only email code and submit it with the same refresh-family session. The six-digit code expires after 10 minutes and can be used once. Reissuing a code invalidates the previous code. Ten invalid proofs block verification for 15 minutes; reissuing does not reset that budget. Refresh within the same family preserves the proof; a new login/family needs a new code. Code requests send JSON {} and are limited to 5/minute and 10/hour per customer in the workspace. A pre-rollout access token needs one refresh before code issuance: use customer.auth.refreshToken() for a direct session or customer.oauth.refresh() for a hosted session, store the returned pair, and retry. These code-specific 409 values are typed by CustomerWithdrawalCodeReauthenticationReason. No new terms acknowledgement or login session is required. Failed or denied delivery and unavailable proof storage return 503 service_unavailable; sandbox capture follows the existing sandbox rules.

import { createClient } from '@01.software/sdk'

type WithdrawalClient = ReturnType<typeof createClient>

export async function requestWithdrawalCode(client: WithdrawalClient) {
  await client.customer.auth.requestWithdrawalCode()
}

export async function withdrawWithEmailCode(
  client: WithdrawalClient,
  emailCode: string,
) {
  const { withdrawnAt } = await client.customer.auth.withdraw({ emailCode })
  return withdrawnAt
}

Immediate or scheduled withdrawal

The call without mode above retains its existing response and membership refusal. To choose explicitly, call withdraw({ mode: 'now' | 'scheduled', currentPassword }). Before immediate withdrawal, explain entitlement termination, show the workspace's ordinary refund policy, and offer its statutory-rights contact separately. Withdrawal does not waive refund or cancellation rights.

A scheduled request fixes executeAt at the latest remaining entitlement end. If no entitlement remains, appliedMode is now. Read the reservation with getWithdrawalReservation(); its only fields are state, request time and execution time. Cancel with cancelWithdrawalReservation({ currentPassword }). A successful cancellation returns renewalResumed: false: it never restarts renewal.

For passwordless accounts, request requestWithdrawalCode({ purpose: 'reservation' }), then supply emailCode instead of currentPassword to either action. This code and a legacy withdrawal code cannot authorize each other. Both purposes share issuance limits and the proof-failure budget. Reissue replaces the previous code of that purpose; a consumed code stays spent even if a later database write rolls back.

ResultMeaningSDK credentials
HTTP 202, scheduled or acceptedReservation committed; archival is not confirmed.Retained locally.
HTTP 200, withdrawnArchival committed at withdrawnAt. Check erasureStatus separately (erased, pending, unknown).Cleared locally.
HTTP 503, retryable: true, Retry-AfterLock acquisition rolled back.Retained locally.
HTTP 503, details.stateConfirmed: falseCommit outcome is uncertain; omitted retryable means false. Read the reservation or me() before deciding another action.Retained locally.

The SDK never automatically retries these POSTs. A cancellation conflict requires refreshing reservation state. After a failed me() read, keep the outcome uncertain until a reservation or receipt query confirms the state. For a non-commerce SDK error, inspect the preserved HTTP envelope under error.details.body; the uncertain result is body.details.stateConfirmed === false.

An uncertain response omits receiptToken. Repeating an existing reservation returns null, so a token lost with a committed response cannot be recovered. If a lease is lost after an email code is consumed, obtain a new code before retrying, even when the response has retryable: true.

Keep the returned receiptToken privately; a repeated submission does not reveal an existing token. getWithdrawalReceipt(receiptToken) works without a customer session and binds the token to the client's publishable-key workspace. Its response contains only processing states and timestamps. Missing, expired and cancelled receipts return the same 404. Responses are not cached; lookup is rate-limited by ingress identity, and 429 carries Retry-After. Receipts expire 30 days after their last state change. Never put a token in a URL or logs.

Hosted session methods

customer.oauth is optional; check that it is configured before calling createAuthorizationUrl, parseCallback, exchangeCode, refresh, or logout. Pass the stored expectedState to parseCallback; the exchange uses the original PKCE verifier and the registered redirect URI. After the exchange, set the access token on the trusted client and call customer.auth.me() to read the identity. Serialize rotation and keep one server-owned access and refresh token pair.

Hosted logout success means the request was accepted; whether a sweep happened is not visible to the app. An unexpired refresh token that the service recognizes for the same workspace and client triggers a sweep, even after rotation or family revocation. A missing, expired, or other-client token does not, and the app cannot tell these cases apart. A sweep covers that customer's clients in the workspace, including legacy-direct sessions, and removes the families issued before that logout's cutoff point. Internal retries of the same logout never revoke families issued at or after that cutoff, so sessions started after the logout survive those retries. A new logout request is separate: if the service recognizes its token, it sweeps again, including sessions started after the earlier logout. Follow the complete sign-in and logout task for cookie cleanup and revocation_unconfirmed handling; a cleanup failure must not replace the original error.

Customer credits

In Console → Customer credits, a tenant-admin sets the default validity (1–1825 days), the redemption cap (1–100%), and fixed or bounded grant templates. Editing a template creates a new immutable version.

Templates show their grant mode, amount and validity. Expand the template ID beside the version to use it in server calls. Under Customer credit account → Select customer, find a customer by name or email, select them and choose Read account. Evaluation and expiry times use the browser’s local timezone.

The customer picker excludes archived, withdrawn and anonymized customers. To read their retained credit history or an unlisted customer, expand Read by ID, enter the customer ID and choose Read account. This fallback also works when the customers feature is off and the picker is unavailable. The server rechecks the workspace and credit read permission; the lookup does not restore customer data or the account.

Other workspace roles can read the configuration and customer history. Archiving a template stops new grants and keeps existing grant snapshots.

Credit settings start off. Turning them off blocks new grants and credit reservations, while existing consumption, release, refund restoration, expiry, revocation, and reads keep working. This switch does not bypass the ecommerce feature requirement.

Use a trusted workspace server with sk01_ credentials for server.credits.grant, getGrant, getAccount, and revoke. Grants and revocations need write scope; reads need read scope. Browser clients have only client.credits.mine() and client.credits.history({ page }), using the signed-in Customer session and Publishable Key.

  • Pages contain at most 50 entries.
  • Balances and history amounts are decimal strings in KRW, including totals larger than JavaScript's safe integer range. Do not convert such totals to Number.
  • Buckets separate the original expiry from the restoration grace period, which each principal portion gets at most once. A grace period lasts 168 hours and is never renewed.

Monthly grant recipe for an annual member

The workspace owns its scheduler, membership eligibility, and cancellation policy. Before running this function, verify that the member qualifies for the selected calendar month. An annual payment alone does not create future monthly grants.

import { createServerClient } from '@01.software/sdk/server'

type CreditServer = ReturnType<typeof createServerClient>

export async function grantMembershipMonth(
  server: CreditServer,
  input: {
    customerId: string
    fixedTemplateId: string
    membershipId: string
    month: string
  },
) {
  const source = {
    sourceNamespace: 'membership-month',
    sourceRef: `${input.membershipId}:${input.month}`, // month: YYYY-MM in the tenant's calendar
  }
  await server.credits.grant({
    ...source,
    customerId: input.customerId,
    templateId: input.fixedTemplateId,
  })
  return server.credits.getGrant(source)
}

export async function revokeMembershipMonth(
  server: CreditServer,
  input: { membershipId: string; month: string },
) {
  return server.credits.revoke({
    sourceNamespace: 'membership-month',
    sourceRef: `${input.membershipId}:${input.month}`,
  })
}
  • Run missed eligible months with their original month identity. Retry an uncertain request with exactly the same template, customer, and source identity.
  • A matching replay grants once, even after configuration changes; a different payload for the same identity conflicts.
  • Fixed templates reject amount; bounded templates need an exact positive integer amount within their maximum.
  • Revocation reports revoked, pending, and spent separately, never creates debt, and sends later release or restoration to the revoked funds.
  • Deciding whether a membership cancellation or refund calls for revocation is the workspace's responsibility. There is no platform cron, campaign engine, cash loading, transfer, or cash-out.

Apply credit to the current Commerce Checkout

Pass an exact positive safe-integer creditAmount with the existing Checkout create request and its original idempotency key. Credit needs verified Customer proof, active ecommerce, enabled credit settings, an available balance, and an amount within the redemption cap. A balance shown in the browser or an Accept header never authorizes payment.

The SDK requests application/vnd.01software.credit-funding.v1+json for successful Checkout create, detail, and query calls. Only that whole Accept value (trimmed, case-insensitive) opts in; lists, wildcards, and parameters do not. The opted-in response uses that Content-Type and Cache-Control: private, no-store; errors keep their ordinary JSON type. The public funding object contains only creditAmount and providerPayableAmount. Show the purchase total (checkout.amounts.totalMinor) separately from these credit and PG amounts. A full-credit placement needs no PaymentSession; a mixed payment charges the frozen provider-payable amount. Refunds keep the original purchase's funding record and restore only the credit attributed to that refund.

Old servers reject the new credit input before any business write. The SDK never strips it, changes it to zero, probes a separate endpoint, or retries without credit. A response without funding is a legacy response with unknown funding support, not proof of zero credit. Missing or inconsistent funding after a credit create is an uncertain result: retrieve the Checkout, or retry the exact same request with the original idempotency key. Never fall back to a full PG charge.

For server.commerce.orders.returnWithRefund, omit pgPaymentId only when the original Order's frozen provider_payable is zero (a full-credit Order). A mixed Order needs its original capture identity even when this refund restores credit only and has a zero provider amount. A full-credit Order rejects any pgPaymentId or paymentKey; never invent an identifier. Explicit blank values stay invalid. Orders without credit funding still return 400/validation_failed when pgPaymentId is omitted. Refund amount, credential, workspace, and ecommerce permission rules are unchanged.

Optional handle password login

A workspace administrator can enable customerAuth.handleLoginEnabled in the existing Admin Panel customer-authentication settings. It is off by default. The setting is for the current account handle, not a separate private login ID. Enable it after the handle migration and server rollout finish.

Existing direct email/password requests keep working. With opt-in, direct client.customer.auth.login({ handle, password }) accepts one handle instead of email; sending both or neither is a validation error. CustomerLoginData keeps its existing email interface, and CustomerPasswordLoginData adds the handle branch. Handle login requires a stored email and local password; accounts without a stored email cannot use it. Turning it off leaves email login, public handles and existing sessions intact.

Hosted OAuth remains the recommended integration for new storefronts. Its server password endpoint also accepts the handle branch, but the hosted sign-in screen does not yet offer handle input. Passwordless codes and password reset remain email-based. A renamed or cleared handle stops matching; the old handle is not a login alias. Login errors and lockout rules stay the same for email and handle.

Next Actions

On this page