Launch API
Create and manage token launches.
Create Launch
POST /launch
Creates a new token launch with optional vesting and DAO configuration.
Request Body
interface CreateLaunchRequest {
// Required
name: string; // Token name (max 32 chars)
ticker: string; // Token symbol (max 10 chars)
totalSupply: number; // Total supply (before decimals)
// Optional
description?: string; // Token description
image?: string; // Image URL (Arweave/IPFS recommended)
decimals?: number; // Token decimals (default: 9)
reservePercent?: number; // % of supply for reserve (default: 0)
beneficiary?: string; // Wallet to receive vested tokens
// Vesting config
vestingConfig?: {
mode: 'time' | 'hybrid';
cliffMonths?: number;
durationMonths?: number;
time?: { // For hybrid mode
percent: number;
cliffMonths: number;
durationMonths: number;
};
};
// DAO config
daoConfig?: {
name?: string; // DAO name (default: token name)
minTokensToCreate?: number; // Min tokens to create proposal
votingTime?: number; // Voting period in seconds
};
// Liquidity config
liquidityConfig?: {
percent: number; // % of reserve for liquidity
initialPrice?: number; // Initial token price in SOL
};
// Webhook
webhookUrl?: string; // URL for status updates
webhookSecret?: string; // HMAC secret for verification
// Upstream linkage + idempotency
externalProjectId?: string; // Upstream project id (e.g. a Studio project), max 128 chars
product?: string; // Upstream product that requested the launch (e.g. "studio"), max 32 chars
idempotencyKey?: string; // Client-generated key (max 64 chars). Re-sending the same
// wallet + key returns the ORIGINAL launch's eventId (202) —
// no new launch, no daily-limit bump. Enforced by a unique
// index on (wallet, idempotencyKey), so concurrent duplicates
// also collapse onto the first launch.
}Non-custodial guarantees
Every launch is executed so that Etch holds nothing of value once the job finishes:
- The mint is created with no freeze authority (set to
nullin the same transaction). - The mint authority is revoked right after on-chain metadata is written (
mintAuthorityRevoked: true), so supply is fixed forever. - All Meteora LP tokens are locked permanently (
lpLocked: true) in two Meteora lock escrows: 90 % owned by thebeneficiary(the creator claims those trading fees) and 10 % owned by the Etch wallet (platformFeeBps: 1000, the platform fee share). Nobody — not the creator, not Etch — can ever withdraw the liquidity; each escrow owner only claims its share of the pool’s trading fees (see Fees API). No LP tokens are ever transferred unlocked. Etch pays for and signs both lock transactions; the creator never has to sign.lpLock.creatorEscrow/lpLock.platformEscroware the two escrow accounts. - Reserve tokens go to the beneficiary, a Streamflow vesting contract, or the DAO treasury depending on the configuration.
Launches made before the lock model (
lpTransferSignatureset,lpLocked: false) handed the LP tokens to the beneficiary unlocked and are left as-is.
The job is resumable and single-flight: every step’s result is persisted immediately, so a retry (QStash) or an admin replay resumes from the last completed step instead of minting a second token. The mint address is derived deterministically per launch and persisted before the create transaction is sent, so a retry checks the chain and skips the send when the mint already exists. Before doing any on-chain work an invocation claims a row lock; a second invocation that overlaps a live run (QStash retry racing a slow run, admin replay mid-run) returns 409 { "status": "in_progress" } with Retry-After: 60 and does nothing — QStash honours the header and re-delivers later. Locks are released when the run ends and expire after 10 minutes; an hourly sweeper re-enqueues any launch whose invocation was killed mid-run (non-terminal status with a lock older than 10 minutes) and parks a dead-letter-queue row for it.
Example Request
curl -X POST https://etch.film.fun/api/tokens/launch \
-H "Authorization: Bearer $ETCH_JWT" \
-H "Content-Type: application/json" \
-d '{
"name": "Awesome Token",
"ticker": "AWE",
"description": "The most awesome token on Solana",
"imageUrl": "https://example.com/awe.png",
"totalSupply": 1000000000,
"beneficiary": "8vP2...",
"allocation": { "launchPercent": 60, "vestingPercent": 20, "daoPercent": 20 },
"vesting": { "mode": "time", "cliffMonths": 6, "durationMonths": 24 },
"dao": { "enabled": true, "name": "Awesome DAO", "passThreshold": 60, "votingPeriodHours": 72 },
"idempotencyKey": "6f0c1f9e-6f1f-4b7e-9d8b-1d1c2a7f3b10"
}'$ETCH_JWT is the token from sign-in. Register webhooks separately with POST /api/webhooks.
Response
202 Accepted
{ "eventId": "0b9f6c2e-3c1d-4d8e-9a51-7f0f2b6d8e11" }Poll GET /api/tokens/launch/status/:eventId (or listen for launch.completed). Errors:
| Status | error | Meaning |
|---|---|---|
| 400 | invalid_request / invalid_dbc / infeasible_curve | Validation failed (see details) |
| 401 | unauthorized | Missing or invalid JWT |
| 403 | dbc_disabled | mode: "dbc" while bonding-curve launches are off |
| 409 | partner_fee_claimer_missing | Mainnet bonding-curve launch without DBC_PARTNER_FEE_CLAIMER |
| 429 | rate_limited | The wallet’s daily launch allowance is used up (resets 00:00 UTC) |
| 429 | platform_daily_limit | Etch’s platform-wide daily cap (ETCH_MAX_LAUNCHES_PER_DAY) is reached |
| 503 | queue_unavailable | The job could not be queued; retry with the same idempotencyKey |
The daily allowance is reserved atomically when the launch is accepted and handed back if the job could not be queued.
Get Launch Status
GET /launch/:launchId
Retrieve the status and details of a launch.
Response
{
"launchId": "launch_abc123xyz",
"status": "complete",
"createdAt": "2026-02-15T12:00:00Z",
"completedAt": "2026-02-15T12:00:30Z",
"config": {
"name": "Awesome Token",
"ticker": "AWE",
"totalSupply": 1000000000,
"reservePercent": 20
},
"stepErrors": {},
"externalProjectId": "studio-project-uuid",
"product": "studio",
"mintAuthorityRevoked": true,
"lpLocked": true,
"platformFeeBps": 1000,
"lpLock": {
"creatorEscrow": "ESCROWc...",
"platformEscrow": "ESCROWp...",
"creatorSignature": "4xyz...",
"platformSignature": "5abc..."
},
"lpTransferSignature": null,
"result": {
"mint": "AWE1234...",
"metadata": "META5678...",
"vestingContract": "VEST9012...",
"dao": {
"realm": "REALM3456...",
"governance": "GOV7890...",
"treasury": "TRES1234..."
},
"pool": {
"address": "POOL5678...",
"lpMint": "LP9012..."
}
},
"transactions": [
{
"type": "mint",
"signature": "5abc...",
"status": "confirmed"
},
{
"type": "vesting",
"signature": "5def...",
"status": "confirmed"
}
]
}Status Values
| Status | Description |
|---|---|
queued | Launch is queued for processing |
creating_token | Mint is being created |
creating_pool | Mint exists; metadata, authority revocation and pool creation in progress |
creating_vesting | Streamflow vesting contract being created |
creating_dao | Realms DAO being created / funded |
success | All steps completed successfully |
partial | Mint, metadata and pool succeeded, but one or more non-fatal steps failed — see stepErrors. The launch is also parked in the dead-letter queue so an admin can replay it |
failed | A fatal step (mint, metadata, mint-authority revoke, pool) failed (see error); the job is retried and parked in the dead-letter queue |
Metadata is fatal on purpose: it must be written while Etch still holds the mint authority, so a launch never ends up with a permanently metadata-less token. Because the mint address is persisted before anything is sent, a retry after a metadata failure resumes at the metadata step with the same mint. The one exception is a mint whose authority is already revoked (an older launch that predates the metadata step): metadata can never be created for it, so the job records stepErrors.metadata and continues instead of failing forever.
Step errors
stepErrors is an object keyed by step name with the failure message. Only non-fatal steps appear here:
| Key | Step |
|---|---|
metadata | Only when the mint authority was already revoked before metadata could be written (otherwise metadata is fatal, see above) |
lp | Permanent LP lock (creator + platform escrows) |
reserve | Legacy reserve transfer to the beneficiary |
vesting | Streamflow vesting contract |
dao | Realms DAO creation |
daoTreasury | DAO treasury funding |
{
"status": "partial",
"stepErrors": { "vesting": "Streamflow: insufficient SOL for rent" },
"mint": "AWE1234...",
"pool": "POOL5678...",
"mintAuthorityRevoked": true
}A partial launch is tradeable (mint + pool exist). Every partial run upserts a pending dead-letter-queue row for the launch (error: "partial: {…stepErrors}"). The hourly cleanup cron re-runs it automatically (15 min backoff, doubling, up to 5 runs counted on that DLQ row); a failed launch that already has a mint on chain is retried the same way, while bad input (validate:), never-queued (queue:) and abandoned launches are not. After that an admin can replay it with POST /api/admin/dlq/:id/replay; the replay re-runs only the steps listed in stepErrors (each is idempotent on-chain: an existing Streamflow stream or Realms realm is reused rather than duplicated) and, when it ends in success, resolves that DLQ row automatically.
LP lock fields
| Field | Description |
|---|---|
lpLocked | true once ALL LP tokens sit in the two lock escrows. The step is idempotent: a replay checks both escrows on-chain and only locks what is still unlocked |
platformFeeBps | Share of LP (basis points of 10 000) locked for the platform — 1000 = 10 %. The remaining 90 % is locked for the beneficiary |
lpLock.creatorEscrow | Meteora lock escrow owned by the beneficiary. Its owner claims the creator share of trading fees |
lpLock.platformEscrow | Meteora lock escrow owned by the Etch wallet (platform share). When the beneficiary is the Etch wallet a single escrow holds 100 % and both fields carry the same address |
lpLock.creatorSignature / lpLock.platformSignature | The two lock transactions (null for a share that was already locked when the job resumed) |
lpTransferSignature | Legacy launches only: the unlocked LP hand-off transaction. null on locked launches |
Wallet pre-flight
Before claiming a launch the job reads the Etch wallet balance. Below ETCH_LAUNCH_MIN_SOL (default 0.5) the launch is marked failed with error: "wallet_low_balance", a dead-letter-queue row is upserted, and the job answers 503 with Retry-After: 300 without touching the chain — QStash re-delivers once the wallet has been topped up, and the retry runs the launch normally. GET /api/health reports the same balance under checks.wallet.
List Launches
GET /launches
List all launches for your account.
Query Parameters
| Parameter | Type | Description |
|---|---|---|
status | string | Filter by status |
limit | number | Max results (default: 20) |
offset | number | Pagination offset |
Example
curl "https://etch.film.fun/api/launches?status=complete&limit=10" \
-H "Authorization: Bearer etch_sk_live_xxxxx"