Developers
Mint Chia NFTs from your own app with one API call. Upload an image, choose who receives the NFT, and MintGarden builds the metadata, stores the files on IPFS and mints on-chain. Our Secure the Mint technology links every NFT to your creator profile for provenance. Each mint uses one prepaid mint credit.
Every NFT you mint is linked to a creator profile (a DID), so collectors can verify who made it.
Create a profile →One credit mints one NFT. Credits are created on-chain for your profile and are not transferable.
Buy credits →Your first credit purchase creates one for you. Send it as a bearer token with every request. The key identifies your profile, so keep it on your server.
Create a key →Buy mint credits, create and revoke API keys, check recent mints and your webhook, and run a test mint from your browser.
Open the dashboard Export your API key as MINTGARDEN_API_KEY. Every example below is complete: copy it, replace the addresses with your own, and run it. The curl examples need jq, the JavaScript examples run on Node 18 or later as an .mjs file, and the Python examples need requests.
Describe the NFT and MintGarden builds the CHIP-0007 metadata, stores both files on IPFS and computes the hashes. Reserve an upload with POST /mint/dynamic/uploads, PUT exactly size_bytes bytes to the returned upload_url with the returned upload_headers before expires_at, then mint it with nft. The example uploads gm-yak.png from the current directory and mints it to target_address. The NFT links the image and its metadata as https://ipfs.mintgarden.io/ipfs/{cid} and ipfs://{cid}.
API=https://api.testnet.mintgarden.io
# 1. Reserve an upload for the image
UPLOAD=$(curl -sS --fail-with-body -X POST "$API/mint/dynamic/uploads" \
-H "Authorization: Bearer $MINTGARDEN_API_KEY" \
-H "Content-Type: application/json" \
--data @- <<EOF
{
"filename": "gm-yak.png",
"content_type": "image/png",
"size_bytes": $(wc -c < gm-yak.png)
}
EOF
)
UPLOAD_ID=$(echo "$UPLOAD" | jq -r .upload_id)
# 2. PUT the file to the returned URL with the returned headers
echo "$UPLOAD" | jq -r '.upload_headers | to_entries[] | "header = \"\(.key): \(.value)\""' \
| curl -sS --fail-with-body --upload-file gm-yak.png -K - "$(echo "$UPLOAD" | jq -r .upload_url)"
# 3. Mint the NFT
curl -sS --fail-with-body -X POST "$API/mint/dynamic" \
-H "Authorization: Bearer $MINTGARDEN_API_KEY" \
-H "Content-Type: application/json" \
--data @- <<EOF
{
"nft": {
"name": "GM Yak #1",
"description": "A yak saying good morning.",
"attributes": [
{
"trait_type": "Background",
"value": "Mint"
}
],
"image": {
"upload_id": "$UPLOAD_ID"
}
},
"target_address": "txch1ysveu6vh0evhuwq30708syfep7nfaed29l0ejee9nw3wjfjalq8scw4ydh",
"royalty_address": "txch1ysveu6vh0evhuwq30708syfep7nfaed29l0ejee9nw3wjfjalq8scw4ydh",
"royalty_percentage": 5
}
EOF Instead of an upload you can pass a public image URL as "image": {"url": "https://…"}. MintGarden fetches it once to hash it, up to 25 MB, and the NFT links that URL. The server must answer directly, without a redirect or a compressed body.
If you host the files yourself, send metadata with their URIs and sha256 hashes. MintGarden mints exactly what you send.
API=https://api.testnet.mintgarden.io
curl -sS --fail-with-body -X POST "$API/mint/dynamic" \
-H "Authorization: Bearer $MINTGARDEN_API_KEY" \
-H "Content-Type: application/json" \
--data @- <<EOF
{
"metadata": {
"data_uris": [
"https://ipfs.mintgarden.io/ipfs/bafybeidcvjjsqfzae37mzpn7njxnlgdtspya2wkrrkaijoyngux5cjth3m/gm-yak.webp"
],
"data_hash": "8e66307cc9eb7cd8d00d0ffdf2cc4bdde0b415921f850b44c070b43291522a2a",
"metadata_uris": [
"https://ipfs.mintgarden.io/ipfs/bafybeidcvjjsqfzae37mzpn7njxnlgdtspya2wkrrkaijoyngux5cjth3m/metadata.json"
],
"metadata_hash": "b9d6f7898cf79169ab31cd2bf5d4f773c27380da3c2b6c70cdecf5737028b7b3",
"edition_number": 1,
"edition_total": 1
},
"target_address": "txch1ysveu6vh0evhuwq30708syfep7nfaed29l0ejee9nw3wjfjalq8scw4ydh",
"royalty_address": "txch1ysveu6vh0evhuwq30708syfep7nfaed29l0ejee9nw3wjfjalq8scw4ydh",
"royalty_percentage": 5
}
EOF Add requested_payments and the API returns an offer instead of minting right away. Whoever takes the offer pays and receives the NFT; the payment goes to target_address. Amounts are in mojos: 1 XCH is 1000000000000 mojos and 1 unit of a CAT is 1000 mojos. Set asset_id to null for XCH or to the CAT's asset id, and request each asset once. reserve_for_seconds lets the offer expire and frees its credit; without it the offer stays open until taken. publish_offer lists the offer on MintGarden.
API=https://api.testnet.mintgarden.io
curl -sS --fail-with-body -X POST "$API/mint/dynamic" \
-H "Authorization: Bearer $MINTGARDEN_API_KEY" \
-H "Content-Type: application/json" \
--data @- <<EOF
{
"metadata": {
"data_uris": [
"https://ipfs.mintgarden.io/ipfs/bafybeidcvjjsqfzae37mzpn7njxnlgdtspya2wkrrkaijoyngux5cjth3m/gm-yak.webp"
],
"data_hash": "8e66307cc9eb7cd8d00d0ffdf2cc4bdde0b415921f850b44c070b43291522a2a",
"metadata_uris": [
"https://ipfs.mintgarden.io/ipfs/bafybeidcvjjsqfzae37mzpn7njxnlgdtspya2wkrrkaijoyngux5cjth3m/metadata.json"
],
"metadata_hash": "b9d6f7898cf79169ab31cd2bf5d4f773c27380da3c2b6c70cdecf5737028b7b3",
"edition_number": 1,
"edition_total": 1
},
"target_address": "txch1ysveu6vh0evhuwq30708syfep7nfaed29l0ejee9nw3wjfjalq8scw4ydh",
"royalty_address": "txch1ysveu6vh0evhuwq30708syfep7nfaed29l0ejee9nw3wjfjalq8scw4ydh",
"royalty_percentage": 5,
"requested_payments": [
{
"asset_id": null,
"amount": 1000000000000
}
],
"reserve_for_seconds": 3600,
"publish_offer": true
}
EOFAPI=https://api.testnet.mintgarden.io
curl -sS --fail-with-body -X POST "$API/mint/dynamic" \
-H "Authorization: Bearer $MINTGARDEN_API_KEY" \
-H "Content-Type: application/json" \
--data @- <<EOF
{
"metadata": {
"data_uris": [
"https://ipfs.mintgarden.io/ipfs/bafybeidcvjjsqfzae37mzpn7njxnlgdtspya2wkrrkaijoyngux5cjth3m/gm-yak.webp"
],
"data_hash": "8e66307cc9eb7cd8d00d0ffdf2cc4bdde0b415921f850b44c070b43291522a2a",
"metadata_uris": [
"https://ipfs.mintgarden.io/ipfs/bafybeidcvjjsqfzae37mzpn7njxnlgdtspya2wkrrkaijoyngux5cjth3m/metadata.json"
],
"metadata_hash": "b9d6f7898cf79169ab31cd2bf5d4f773c27380da3c2b6c70cdecf5737028b7b3",
"edition_number": 1,
"edition_total": 1
},
"target_address": "txch1ysveu6vh0evhuwq30708syfep7nfaed29l0ejee9nw3wjfjalq8scw4ydh",
"royalty_address": "txch1ysveu6vh0evhuwq30708syfep7nfaed29l0ejee9nw3wjfjalq8scw4ydh",
"royalty_percentage": 5,
"requested_payments": [
{
"asset_id": "a628c1c2c6fcb74d53746157e438e108eab5c0bb3e5c80ff9b1910b3e4832913",
"amount": 1000
}
],
"reserve_for_seconds": 3600,
"publish_offer": true
}
EOFPOST /mint/dynamic takes a JSON body. Send either nft or metadata, never both. In simple mode the metadata MintGarden builds may be up to 1 MB.
| Field | Type | Description |
|---|---|---|
nftnft or metadata | SimpleNft | Simple mode. Describe the NFT and MintGarden builds and stores the metadata. |
metadatanft or metadata | MetadataListEntry | Raw mode. URIs and sha256 hashes of files you host yourself. |
target_addressRequired | string | Direct mint: the address that receives the NFT. Mint offer: the address that receives the payment. |
royalty_addressRequired | string | The address that receives royalties. |
royalty_percentageRequired | number | Royalty on resales in percent, from 0 to 100, for example 5 for 5%. Rounded down to two decimals. |
requested_payments | RequestedPayment[] | Create a mint offer that asks for these payments, at most one per asset. Omit it or send [] to mint directly. |
reserve_for_seconds | integer | Mint offers only: the offer expires after this many seconds and its credit can mint something else. Omit it, or send null or 0, and the offer stays open until taken. Must not be negative. Ignored for a direct mint, whose credit is held with no expiry. |
publish_offer | boolean | Mint offers: list the offer publicly on MintGarden. Defaults to false. |
deduplication_key | string | Up to 128 characters, unique per profile. Makes retries safe, see Idempotency. |
profile_id | string | Your profile as did:chia:1… or hex. Optional, as the API key identifies the profile; when sent it must match. |
requested_mojosDeprecated | integer | Use requested_payments instead. Cannot be combined with requested_payments. |
requested_asset_idDeprecated | string | Use requested_payments instead. |
| Field | Type | Description |
|---|---|---|
nameRequired | string | The NFT name. Up to 256 characters. |
description | string | The NFT description. Up to 10 000 characters. |
attributes | NftAttribute[] | Traits, each with trait_type and value. Up to 200. |
imageRequired | ImageSource | Exactly one of upload_id or url. |
collection | CollectionInfo | The collection this NFT belongs to. |
sensitive_content | boolean | Marks the NFT as sensitive. Defaults to false. |
license_url | string (URL) | A license document. MintGarden fetches it once to hash it, up to 1 MB, with the same rules as an image URL, and the NFT links this URL. |
edition_number | integer | Edition number, from 1 to edition_total. Send it together with edition_total or not at all. |
edition_total | integer | Total editions, at least 1. |
| Field | Type | Description |
|---|---|---|
upload_id | string | The upload_id from POST /mint/dynamic/uploads. |
url | string (URL) | A public image URL. MintGarden fetches it once to hash it, up to 25 MB, and the NFT links this URL. It must answer 2xx from a public address, without a redirect or a compressed body. |
| Field | Type | Description |
|---|---|---|
trait_typeRequired | string | The trait name, for example Background. Up to 256 characters. |
valueRequired | string | number | The trait value. A string is up to 256 characters. |
| Field | Type | Description |
|---|---|---|
idRequired | string | The CHIP-0007 collection id, a UUID. |
nameRequired | string | The collection name. Up to 256 characters. |
attributes | NftAttribute[] | Collection attributes such as website or twitter. Up to 200. |
| Field | Type | Description |
|---|---|---|
asset_idRequired | string | null | A CAT asset id, 64 hex characters without 0x, or null for XCH. |
amountRequired | integer | In mojos, 0 or more. 1 XCH = 1000000000000 mojos, 1 CAT unit = 1000 mojos. |
| Field | Type | Description |
|---|---|---|
data_urisRequired | string[] | Where the NFT file is hosted. |
data_hashRequired | string | sha256 of the NFT file, in hex. |
metadata_urisRequired | string[] | Where the CHIP-0007 JSON is hosted. |
metadata_hashRequired | string | sha256 of the metadata file, in hex. |
license_uris | string[] | Where the license is hosted. |
license_hash | string | sha256 of the license file, in hex. |
edition_number | integer | Defaults to 1. |
edition_total | integer | Defaults to 1. |
| Field | Type | Description |
|---|---|---|
filenameRequired | string | The file name, for example gm-yak.png. |
content_typeRequired | string | The MIME type, for example image/png. |
size_bytesRequired | integer | The exact file size, up to 100 MB and within your storage quota. The uploaded file must match it. |
POST /mint/dynamic returns 200 once the mint is on its way. A direct mint is already in the mempool; a mint offer is ready to share. Errors return a JSON body with detail and code, see Errors.
| Field | Type | Description |
|---|---|---|
nft_id | string | The launcher id in hex. Use it to look up the mint status. |
offer_string | string | null | Mint offers: the offer1… string to share. Otherwise null. |
offer | object | null | Mint offers: the offer as listed on MintGarden. Otherwise null. |
{
"nft_id": "5b8f3c0e8a4f4c7b9e0a2d1c6f7e8d9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e",
"offer": null,
"offer_string": null
}| Field | Type | Description |
|---|---|---|
upload_id | string | Pass it as nft.image.upload_id. |
upload_url | string | PUT the file bytes here. |
upload_headers | object | Send these headers with the PUT, unchanged. |
expires_at | datetime | When upload_url stops accepting the PUT. |
GET /mint/dynamic/{nft_id} returns the status of an NFT you minted with the API. It accepts the nft_id from the mint response or the nft1… id. Poll it every few seconds, or let a webhook tell you when the mint is confirmed.
The nft_id is fixed by the credit. When a credit is used again, for example after its offer expired, the new mint gets the same nft_id, and the lookup answers for the request that holds the credit. reserved_until is set while the mint is pending or offered and has an expiry, and confirmed_at_height once it is confirmed.
curl -sS --fail-with-body "https://api.testnet.mintgarden.io/mint/dynamic/$NFT_ID" \
-H "Authorization: Bearer $MINTGARDEN_API_KEY"| Status | Meaning |
|---|---|
| submitting | The API call is still running. It becomes pending, offered or failed when the call returns. |
| pending | A direct mint was sent to the network and waits to be included in a block. |
| offered | A mint offer was created and waits for someone to take it, until reserved_until when that is set. |
| confirmed | Seen in a block. MintGarden does not revert this status after a chain reorganization; wait for more blocks after confirmed_at_height if you need finality. Final. |
| expired | The reservation ran out, or another request now holds the credit. This attempt is not expected to mint, but an offer taken just before the deadline can still turn confirmed once its block is indexed. |
| failed | The API call failed or was interrupted, and the request no longer holds a credit. After chain_submit_failed or lease_lost its spend can still land while this status stays failed, so check the NFT page before you retry. A retry with the same deduplication_key runs as a new mint. Final. |
{
"launcher_id": "5b8f3c0e8a4f4c7b9e0a2d1c6f7e8d9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e",
"encoded_id": "nft1tw8ncr52fax8h8s295wx7l5dng93ctf7fadxklydnc8352euf40q4ac9zk",
"status": "confirmed",
"mode": "direct",
"deduplication_key": "gm-yak-1",
"created_at": "2026-10-25T12:00:00Z",
"confirmed_at_height": 6512345,
"reserved_until": null,
"offer_string": null,
"url": "https://mintgarden.io/nfts/nft1tw8ncr52fax8h8s295wx7l5dng93ctf7fadxklydnc8352euf40q4ac9zk"
} Network errors happen. Send a deduplication_key (up to 128 characters, unique per profile) and you can retry a mint without minting twice or spending a second credit.
200 with the stored response and the header Idempotent-Replayed: true. Only server-side code sees this header: the API does not expose it to browsers through CORS. profile_id aside): 422 with code deduplication_key_reused. 409 with code deduplication_in_progress. Wait a moment and retry. chain_submit_failed or lease_lost the first spend can still land, so check the NFT first, see Errors. Instead of polling, MintGarden can call your server when a mint is confirmed. Set one HTTPS URL per profile with PUT /mint/dynamic/webhook. It returns url, created_at and a secret that is shown only once; every PUT rotates it. The URL must be https, at most 2048 characters, and its host must resolve to a public address within 5 seconds, or the call answers 422 with invalid_request.
GET shows the current url and created_at, and DELETE removes the webhook along with every delivery still waiting. Without a webhook both answer 404 with webhook_not_configured. POST /mint/dynamic/webhook/test queues a ping and answers 202 with its delivery_id. While that ping is still waiting, another test answers 200 with the same delivery_id and sends it right away.
curl -sS --fail-with-body -X PUT "https://api.testnet.mintgarden.io/mint/dynamic/webhook" \
-H "Authorization: Bearer $MINTGARDEN_API_KEY" \
-H "Content-Type: application/json" \
--data '{"url": "https://example.com/mintgarden/webhook"}'
# Send a test "ping" delivery
curl -sS --fail-with-body -X POST "https://api.testnet.mintgarden.io/mint/dynamic/webhook/test" \
-H "Authorization: Bearer $MINTGARDEN_API_KEY"mint.confirmed: the mint was seen in a block. It is sent once per NFT, and data is the MintStatus of the request that confirmed the credit: the request holding the credit if it was submitted, otherwise the newest submitted request for that NFT. ping: a test delivery. data is {"profile_id": "…"}.POST https://example.com/mintgarden/webhook
Content-Type: application/json
MintGarden-Signature: t=1792900800,v1=5f0c1e…
MintGarden-Event: mint.confirmed
MintGarden-Delivery: 3f9a2c64-1d0e-4c5b-9a7e-2b8d6f1e0c3a
{
"id": "3f9a2c64-1d0e-4c5b-9a7e-2b8d6f1e0c3a",
"type": "mint.confirmed",
"created_at": "2026-10-25T12:01:30Z",
"data": { "launcher_id": "…", "encoded_id": "nft1…", "status": "confirmed", … }
}MintGarden-Signature has the form t=<unix seconds>,v1=<signature>, where the signature is the hex HMAC-SHA256 of <t>.<raw body> keyed with your webhook secret. Compute it over the raw request body, compare in constant time, and reject old timestamps so a captured request cannot be replayed.
import crypto from 'node:crypto';
// rawBody is the exact request body string, before any JSON parsing.
export function verifyMintGardenSignature(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(signatureHeader.split(',').map((part) => part.split('=', 2)));
const timestamp = Number(parts.t);
if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest();
const received = Buffer.from(parts.v1 ?? '', 'hex');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}2xx within 10 seconds. Anything else counts as a failure, and so does a redirect, which is not followed. id (the same as MintGarden-Delivery) to ignore repeats. mint.confirmed fires on the first confirmation and is not retracted after a chain reorganization, and the confirmed status is not reverted either. If you need finality, wait for more blocks after confirmed_at_height. Errors return a JSON body with a human readable detail and a stable code. Branch on code, not on the message.
Errors raised after the mint was sent, lease_lost and chain_submit_failed, add nft_id next to code. Do not retry them automatically: the spend may still land. Wait a minute, then look at GET /mint/dynamic/{nft_id} and the NFT page /nfts/{nft_id}. The request itself stays failed, so the NFT page is where a landed spend shows up. A retry with the same deduplication_key replaces the failed attempt and mints a second NFT if the first spend lands.
A body that does not match the schema, such as a missing field or a string over its length limit, gets 422 with code invalid_request. Its detail is then a list of field errors, each with loc (the path to the field) and msg.
{
"detail": "No available credits.",
"code": "no_credits"
}| Code | HTTP | When |
|---|---|---|
| missing_api_key | 401 | No Authorization: Bearer header. |
| invalid_api_key | 401 | The key is unknown or revoked. |
| profile_mismatch | 403 | The profile_id in the body is not the profile of the key. |
| minting_blocked | 403 | The profile is blocked from minting. Every endpoint that takes an API key answers this. |
| no_credits | 402 | The profile has no unspent credit. Buy more in the dashboard. |
| invalid_request | 422 | The body is invalid: both or neither of nft and metadata, both or neither of image.upload_id and image.url, both payment styles, the same asset requested twice, a bad address, asset id or amount, a royalty outside 0 to 100, a negative reserve_for_seconds, editions out of range, metadata over 1 MB, or a webhook URL that is not a public https address. |
| deduplication_key_reused | 422 | The deduplication_key was used for a different request. |
| deduplication_in_progress | 409 | The first request with this key is still running. |
| lease_lost | 409 | Another request took this credit before the mint was recorded, and this request failed. Its spend can still land. Not safe to retry right away, see below. |
| mint_not_found | 404 | Status lookup for an NFT this profile did not mint with the API. |
| upload_not_found | 404 | The upload_id is unknown or belongs to another profile. |
| upload_not_ready | 409 | The file is not in storage yet, storage has not pinned it yet, or another mint of the same upload is in progress. Retry shortly. |
| upload_too_large | 413 | The upload is over 100 MB or the storage quota, or the uploaded file is not the size_bytes reserved for it. |
| fetch_failed | 422 | An image or license URL was refused, unreachable, answered with a redirect or an error, sent a compressed body, or is over its size limit. Not retried automatically: retry only if you know the host was briefly unreachable. |
| chain_submit_failed | 502 | Sending the mint to the network failed, and MintGarden could not confirm that the network has the spend. The credit was released, but the spend can still land. Not safe to retry right away, see below. |
| offer_upload_failed | varies | The mint offer could not be stored. The status and detail are those of the offer upload. The credit was released. Retry when the status is 5xx. |
| webhook_not_configured | 404 | Webhook read, test or delete without a webhook. |
The OpenAPI reference lists every endpoint and schema of the Dynamic Minting API: https://api.testnet.mintgarden.io/docs#tag/Dynamic-Minting.