GraveMint SDK
@solanadeads/gravemint · version 1.2.1 · install with npm i @solanadeads/gravemint
This page is generated from the published tarball — it is byte-for-byte the README inside node_modules/@solanadeads/gravemint at v1.2.1. It is not a summary and it cannot drift.
The official SDK for GraveMint — put a real, complete mint page on your own site, using our backend for all of it.
Not a widget and not an iframe: you get the data, you render it however you like, and the collector never leaves your domain.
- Zero runtime dependencies. Native
fetch. Node 18+, browsers, Bun, Deno. - No chain SDK bundled. You bring your own wallet library, so this cannot collide with whatever you already use.
- Dual build. ESM + CJS. Full
.d.ts. - Versioned contract.
v1is additive-only — see Versioning.
npm install @solanadeads/gravemint✅ Published.
npm install @solanadeads/gravemintgives you the mint-capable generation documented below —gm.v1andgm.mintincluded. Partner API keys are invite-only: ask us for one, then see Getting a key.
The one rule worth reading first
You sign. We broadcast.
prepare() gives you a transaction GraveMint built. Your wallet signs it. The signed bytes go back to execute(), and GraveMint submits them.
This is not stylistic. Before broadcasting, GraveMint verifies the signed transaction is byte-for-byte the one it prepared — recipient, amount and instructions all pinned. That check only means anything if GraveMint is the one broadcasting. If your page submitted the transaction itself, the guard would be bypassed and your page would become the thing deciding what lands on chain.
There is deliberately no submit method in this SDK. If you find yourself wanting one, you are about to remove the protection that makes the prepared transaction trustworthy.
Contents
- Quick start · Getting a key · Configuration
- Building a mint page — phases · price · eligibility · capabilities
- Minting — the signer · wallet-adapter example
- Errors and retries · Rate limits
- Versioning · Legacy read API · Security
Quick start
import { GraveMintClient } from "@solanadeads/gravemint";
const gm = new GraveMintClient({ apiKey: "gm_pub_..." });
// 1. Everything needed to render the drop, in one call.
const drop = await gm.v1.collection("deads");
console.log(drop.collection.name);
console.log(drop.stats.mintedCount, "/", drop.stats.totalSupply);
// 2. Which phase is live right now (server-decided, not client-computed).
const phase = drop.phases.active[0];
// 3. Can this wallet mint it?
const verdict = await gm.v1.eligibility(drop.collection.id, phase.id, wallet);
// 4. Mint. You supply the signer; GraveMint broadcasts.
const result = await gm.mint.mint({
collectionId: drop.collection.id,
phaseId: phase.id,
quantity: 1,
walletAddress: wallet,
signer,
});Getting a key
Partner keys are issued by GraveMint, not self-service. There is no signup page — the surface is invite-only while it beds in. Talk to us and we will issue one scoped to your collections and your domains.
Two classes, and the difference matters:
| Class | Prefix | Where it belongs | What bounds it |
|---|---|---|---|
| Publishable | gm_pub_ | your web page — it is meant to be visible | the origin allow-list on the key |
| Secret | gm_live_ | your server only | secrecy — it has no origin binding |
A publishable key is public by construction; treating it as a secret is the wrong mental model. What protects it is that it only works from origins registered on the key itself. So the important thing is not hiding it — it is telling us every origin you serve from, including staging and preview domains. A missing origin is a 403 that looks like an outage.
Never put a gm_live_ key in browser code. It has no origin binding, so a leak is unbounded until it is revoked.
⚠️ The converse also bites: a gm_pub_ key does NOT work server-side. It is admitted by matching the request's Origin against the key's allow-list, and a Node process sends no Origin — so a publishable key used from a server is refused with ORIGIN_NOT_ALLOWED, every time, with no way to satisfy it. Server calls need gm_live_.
Configuration
new GraveMintClient({
apiKey: "gm_pub_...", // required
timeoutMs: 30_000,
maxRetries: 3, // 429 / 5xx / network
rateLimit: { requestsPerSecond: 5, burst: 10 }, // null to disable
fetch: globalThis.fetch,
});Base URLs — there are two, on purpose
The versioned contract is a separate mount from the legacy public read API, with its own auth and its own middleware. The client keeps two base URLs:
| default | used by | |
|---|---|---|
baseUrl | https://api.solanadeads.com/gravemint/public | the legacy read API |
v1BaseUrl | https://api.solanadeads.com/gravemint/v1 | gm.v1, gm.mint |
You normally set neither. If you set baseUrl to something ending in /public, v1BaseUrl is derived for you. If it ends in anything else — because you are routing through your own proxy — the constructor throws and asks you to pass v1BaseUrl explicitly.
That is deliberate. Guessing would surface as a 404 for a collector mid-mint; throwing surfaces at startup, for you.
// Proxying through your own backend:
new GraveMintClient({ apiKey, v1BaseUrl: "https://yoursite.com/api/gravemint" });Node <18 needs a fetch polyfill: new GraveMintClient({ apiKey, fetch }).
Building a mint page
Most of a mint page is reading, not minting. Live GraveMint collections average 5 phases (the largest has 38), and about a quarter gate eligibility. A client that can only mint cannot render two-thirds of drops.
gm.v1.collection(identifier) returns all of it in one call. identifier is a shortId, a collection UUID, or an on-chain address.
const drop = await gm.v1.collection("deads");
drop.collection // name, symbol, image, chain, socials, isVerified
drop.stats // totalSupply, mintedCount, availableCount, percentMinted
drop.phases // { all, active, upcoming }
drop.capabilities // can you render this drop faithfully?
drop.serverTime // our clock — see belowThe rule that keeps you correct
Every value here is a server verdict. Do not recompute any of them.
That is not a style preference. Pricing, eligibility and phase state each depend on data that is deliberately not sent to you, so a client that re-derives them will disagree with what actually happens at mint time — and disagree silently.
Phases and countdowns
for (const phase of drop.phases.all) {
phase.status // "active" | "upcoming" | "ended"
phase.name
phase.startDate // ISO, or null
phase.endDate
phase.maxPerWallet // null = no cap
phase.maxPerTransaction
phase.phaseSupply
phase.isGated // does it have eligibility requirements?
phase.bogo // { buy, get } or null
phase.msUntilStart // ← render countdowns from these
phase.msUntilEnd
}Two things people get wrong:
phases.all includes ended phases. That is intentional — a schedule that silently omits what has closed makes a multi-phase drop look shorter than it is. Filter if you do not want them.
Do not count down with Date.now(). Use msUntilStart / msUntilEnd, which we derive from our clock. A viewer's device clock can be minutes out, and "starts in −3s" on a phase that never opens is worse than no countdown. If you need a ticking display, anchor once against drop.serverTime and tick locally.
Price is a shape, not a number
type PriceDisplay =
| { kind: "amount"; amount: number; currency: string; isFree: boolean; approximate?: boolean }
| { kind: "range"; min: number; max: number; currency: string }
| { kind: "hidden" }
| { kind: "unknown" };Render it by branching, and treat all four as normal:
switch (phase.priceDisplay.kind) {
case "amount":
return phase.priceDisplay.isFree
? <>Free</>
: <>{phase.priceDisplay.approximate ? "~" : ""}{phase.priceDisplay.amount} {phase.priceDisplay.currency}</>;
case "range":
return <>{phase.priceDisplay.min}–{phase.priceDisplay.max} {phase.priceDisplay.currency}</>;
case "hidden":
return <>Price revealed when you qualify</>;
case "unknown":
return <>Price shown at checkout</>;
}Why it is not just a number. GraveMint's price resolver runs ten stages where later ones overwrite earlier ones — base price, pack tier, native peg, USD peg, dynamic Dutch, BOGO, generative fee, free-mint bounty, claim code, XP discount. Six of them draw on data no public read returns. A reassembled price will not match what the collector is charged.
hidden and unknown are not errors. About a tenth of live phases deliberately conceal the price until a wallet qualifies — that is the creator's choice and it is enforced on our side, not left to you. And a dynamic-Dutch price genuinely cannot be computed for an anonymous caller.
🚨 Never render
hiddenorunknownas0or "Free". That is a real bug we shipped once in our own embed widget and had to fix. If you find yourself writingprice ?? 0, stop.
The authoritative figure — what the collector actually pays — comes back from prepare() as pricing. priceDisplay is for the page before a mint is requested; the real number only exists once the full cascade has run.
Eligibility
const v = await gm.v1.eligibility(collectionId, phaseId, walletAddress);
v.gated // does this phase gate at all?
v.meetsRequirements // the verdict
v.onAllowlist
v.spotsAllocated
v.spotsRemaining
v.requirements // [{ type, met, description }] — render these
v.phase // { id, name, hasStarted, hasEnded }
v.messageThis is server-evaluated because it has to be: the dominant gate is an NFT-holding check needing an on-chain lookup, and an allowlist must never be published to a client.
meetsRequirements deliberately ignores the clock. It answers "will this wallet qualify", with timing reported separately as phase.hasStarted / hasEnded. So an upcoming phase reads as eligible but not yet open rather than "not eligible" — which is what lets you show "you're on the list, opens in 2h" instead of turning qualified collectors away.
const canMintNow = v.meetsRequirements && v.phase.hasStarted && !v.phase.hasEnded;The rest of the drop — art, feed, bounty, per-wallet counts
These are all scoped to your key the same way collection() is: a key issued for another drop gets 403 COLLECTION_NOT_IN_SCOPE, never an empty list.
await gm.v1.gallery("deads", { limit: 24 }); // the drop's art
await gm.v1.recentlyMinted("deads"); // the live "just minted" feed
await gm.v1.bounty("deads"); // bounty summary, when there is one
await gm.v1.bountyPrizes("deads"); // its public prize table
await gm.v1.walletMints("deads", wallet); // "you have minted 2 of 3", per phase🚨 Do not derive walletMints from eligibility(). The two count differently on purpose: walletMints includes bonus mints (BOGO, bounty) and the eligibility engine excludes them. For any wallet that took one, a figure you compute yourself is wrong in the direction that lets a collector believe they have mints left and get refused at prepare.
Claim codes
17.6% of live drops use them, so an integration that ignores claim codes cannot render one drop in six correctly.
await gm.v1.claimCodes(collectionId); // does this drop use codes?
await gm.v1.claimCodeBenefits(collectionId, wallet); // what has this wallet earned?
await gm.v1.validateClaimCode(collectionId, 'CODE-123'); // { valid, grantType, ... }🚨 validateClaimCode is scoped and READ-ONLY. It does not redeem the code or consume a use. A code belonging to a different drop answers byte-identically to an unknown code — so it cannot be used to discover that a code exists somewhere else.
Pass the code through to prepare when you mint; the server applies the benefit.
Pricing helpers
await gm.v1.tokenPrices(); // platform token prices, for an SPL-priced drop
await gm.v1.peggedPrice(phaseId); // resolved amount for a USD/native-pegged phase
await gm.v1.dutchPrice(phaseId); // live price of a dynamic Dutch phase⚠️ Never render phase.price for a pegged phase. It is Dutch-resolved but not peg-resolved, so there it is a stale cached token amount — one live phase carries 110000 TOUCHGRASS in that field.
Both phase routes are scoped through the phase's own collection: a phase id is not a way around collection scope.
Supply, traits and social proof
await gm.v1.availability(collectionId); // mintable now, accounting for held reservations
await gm.v1.traits(identifier); // trait names and values, for gallery filters
await gm.v1.allMinted(identifier); // every minted item
await gm.v1.topHolders(identifier); // largest holders
await gm.v1.topMinters(identifier); // who minted the mostCapabilities — when you cannot render a drop
drop.capabilities // { requiresFeatures: string[], supportedBySurface: boolean, mintUrl: string | null }v1 covers core mint: supply, phases, pricing (including BOGO, pegged prices and hidden prices), eligibility, per-wallet limits, and minting. Some drops are built around mechanics it does not model — packs, gallery selection, generative builders, claim codes, cross-chain payment.
if (!drop.capabilities.supportedBySurface) {
return <a href={drop.capabilities.mintUrl}>Mint on GraveMint</a>;
}Please actually implement this branch. Additive-only versioning stops a pinned client from crashing; it cannot stop one from being quietly wrong, which is worse — a panel that looks fine and misstates the deal, with the collector finding out at checkout or never. supportedBySurface is how a client written today stays correct as new mechanics ship.
The check is a whitelist: a requirement we have not classified makes a drop unsupported rather than optimistically supported. mintUrl is present even when supported, so you can always offer "view the full drop page".
Minting
const prepared = await gm.mint.prepare({
collectionId, phaseId, quantity: 1, walletAddress,
affiliateCode, // optional; validated server-side, ignored if unused
});
const signed = await signer.signTransaction(prepared.transactions[0].transaction);
const result = await gm.mint.execute({
sessionId: prepared.sessions[0].sessionId,
signedTransaction: signed,
});
v1always returns the batch shape —transactions[]andsessions[]— even forquantity: 1. The first-party/gravemint/mint/prepareroute rewrites single mints into a flatter legacy shape with a top-levelsessionId; that rewrite lives in that route's wrapper, and v1 calls the handler directly. So readsessions[0].sessionId, notsessionId. Arrays for everything is the more honest shape anyway — a pack mint genuinely produces several.gm.mint.mint()handles this for you.
Or in one call:
const result = await gm.mint.mint({
collectionId, phaseId, quantity: 1, walletAddress, signer,
});prepare() does the real work — eligibility, limits, pricing, supply reservation, transaction build — and returns pricing (the authoritative breakdown), sessionId and expiresAt.
Sessions expire. Do not hold a signature and submit it later; prepare again. A prepared session also reserves supply, so do not call prepare() just to read a price — that is what priceDisplay is for.
The signer
interface TransactionSigner {
signTransaction(base64Transaction: string): Promise<string>;
}One method, base64 in and base64 out. That narrowness is the point: the less this SDK knows about your wallet stack, the fewer ways it breaks when you change it.
A real signer (@solana/wallet-adapter)
import { VersionedTransaction } from "@solana/web3.js";
const signer = {
async signTransaction(base64: string) {
const tx = VersionedTransaction.deserialize(Buffer.from(base64, "base64"));
const signed = await wallet.signTransaction(tx);
return Buffer.from(signed.serialize()).toString("base64");
},
};Most wallet wrappers already expose this shape.
Chain support
v1 is Solana-first, which is where 81% of live collections are and — not coincidentally — where our server-side transaction integrity guarantee is strongest. Drops on chains this surface cannot complete in-app report it through capabilities.supportedBySurface, so check that rather than discovering it at prepare().
Errors and retries
Every error carries a machine-readable code, a human message safe to show a collector, plus .status and .requestId for support.
import { GraveMintError, RateLimitError } from "@solanadeads/gravemint";
try {
await gm.mint.mint({ ... });
} catch (err) {
if (err instanceof GraveMintError) {
console.error(err.code, err.requestId);
showToast(err.message); // safe: no internals, no schema details
}
}🚨 Read this before branching on a code
v1 returns codes from three different places, and only one set is frozen. Measured against the source, not assumed:
v1's own surface — auth, scope and lookup. Frozen and documented below.
Middleware on the mint path —
RATE_LIMITED,COLLECTION_RATE_LIMITED,WALLET_BLOCKED, plus bot-detection codes.The shared mint handlers, which v1 reuses rather than forks. This set is large, not frozen, and deliberately not enumerated here — three separate mechanisms feed it: a 29-entry registry (
apps/api/src/utils/mintErrors.js, sent viasendKnownError), ~40 inlinesendCodedErrorcodes, and the per-transactionerrorCodevalues described below. Representative examples:NO_NFTS_AVAILABLE,PHASE_ENDED,WALLET_LIMIT_EXCEEDED,SESSION_NOT_FOUND,INSUFFICIENT_FUNDS,NOT_WHITELISTED.🚨 Do not treat any list of these as complete. Four separate attempts to enumerate them while writing this document were each wrong, because each mechanism needs a different search. Some rejections also arrive with a
messageand no code at all. Branch on the frozen set; display the rest.
🚨 A batch mint reports per-transaction failures somewhere else entirely.execute on a multi-NFT mint returns HTTP 200 with a results array, and each entry carries its own errorCode — TX_MODIFIED, SESSION_EXPIRED, NFT_MISMATCH, SESSION_CORRUPTED and the XRPL accept codes live there, not in the thrown error's code. A client branching only on err.code will never see them:
const res: any = await gm.mint.mint({ ... });
if (res.partial) {
for (const r of res.results ?? []) {
if (!r.success) console.warn(r.sessionId, r.errorCode); // e.g. TX_MODIFIED
}
}So: branch on the frozen codes, and treat everything else as a displayable message. Do not assume a code exists.
const code = err instanceof GraveMintError ? err.code : undefined;
if (code === "TX_MODIFIED") { /* never retry — see below */ }
else if (code === "ORIGIN_NOT_ALLOWED") { /* config: send us the origin */ }
else { showToast(err.message); } // covers the unfrozen and code-less casesFrozen — v1's own codes
| Code | Meaning | Retry? |
|---|---|---|
COLLECTION_NOT_FOUND | no such drop, or not publicly servable | no |
LOOKUP_FAILED | we could not read our own data | yes, backoff |
COLLECTION_REQUIRED | the request named no collection | no — fix the call |
CODE_REQUIRED | a claim-code check arrived with no code | no — fix the call |
API_KEY_REQUIRED / API_KEY_INVALID | missing or wrong key | no — fix config |
API_KEY_INACTIVE / API_KEY_EXPIRED | key revoked or lapsed | no — talk to us |
ORIGIN_NOT_ALLOWED | valid key, unregistered origin (or a gm_pub_ key used server-side) | no |
COLLECTION_NOT_IN_SCOPE | valid key, not issued for this drop | no |
AUTH_UNAVAILABLE | we could not check your key — we fail closed | yes, backoff |
Also frozen, and emitted by the shared mint handlers: COLLECTION_CLOSED · NFTS_LOCKED · SESSION_EXPIRED · TX_MODIFIED.
🚨 WHERE a mint error appears depends on the quantity. For a single NFT the server promotes the per-transaction code to the top level (sendCodedError(res, result.error, 400, result.errorCode || 'MINT_FAILED')), so err.code === 'TX_MODIFIED' works. For a batch the codes stay inside results[].errorCode and err.code is never set. Handle both — and note that any quantity above 1 is a batch, because every Solana standard mints one NFT per transaction.
🔒 You sign; WE submit. v1 will not accept a transaction you have already broadcast yourself — a transaction we never inspected is one whose recipient, amount and instructions we never verified. Two codes enforce that, and both are TERMINAL (retrying the same body cannot succeed):
CLIENT_BROADCAST_NOT_ALLOWED— the request carriedtransactionHash(ortxHash/transaction_hash). Sign the transactionprepare-mintreturned and send it back assignedTransaction; we submit it and confirm it.CHAIN_NOT_SUPPORTED_BY_SURFACE— the drop is on a chain this API does not serve. v1 is Solana-only. This is distinct fromCHAIN_DISABLED, which means a chain was retired platform-wide; your drop's chain is fine, it is just out of scope here. UsemintUrlfrom the collection response to send the collector to GraveMint instead.
Frozen but not emitted on the v1 path: CHAIN_DISABLED · UNSUPPORTED_BY_SURFACE (not emitted anywhere yet), and NOT_ELIGIBLE · WALLET_MISMATCH (emitted elsewhere in GraveMint, on the cross-chain and mint-page-token paths — neither of which v1 mounts). They will not be renamed.
The eligibility endpoint
gm.v1.eligibility() reuses the first-party handler, so it has its own two codes. Neither is frozen:
| Code | Meaning | Retry? |
|---|---|---|
INVALID_ADDRESS | the wallet address is not valid for this chain | no — fix the call |
CHECK_BUSY | the eligibility engine is at its global budget | yes, short backoff |
Rate limiting and suspension
| Code | Meaning | Retry? |
|---|---|---|
RATE_LIMITED | too many requests for this key | yes, honour Retry-After |
COLLECTION_RATE_LIMITED | too many prepares against this collection, from everyone | yes, honour retryAfter |
WALLET_BLOCKED | this wallet is suspended | no — on prepare-mint the body also carries reason / blockedUntil / permanent; on execute-mint it is the code and message only |
ASSET_BANNED | this collection or token is on the platform ban list | no |
🚨 Never retry
TX_MODIFIED. It means the bytes we were asked to broadcast differ from the bytes we built — different recipient, different amount, added instructions. Re-submitting the same bytes cannot succeed and should not. Treat the attempt as fatal, discard the session, and start over with a freshprepare(). If it recurs, something is modifying transactions between us and the wallet, and that is worth telling us about.
ORIGIN_NOT_ALLOWED and API_KEY_REQUIRED are kept distinct on purpose so you can tell "I forgot my key" from "my domain is not registered" — the second is a one-line fix on our side.
The SDK retries 429/5xx/network automatically with exponential backoff, honouring Retry-After, up to maxRetries. It does not auto-retry the terminal codes above.
Rate limits
| Limit | Value | Scope |
|---|---|---|
Reads on /v1 (shared with /gravemint/public) | 600 / minute | per IP |
/v1/prepare-mint and /v1/execute-mint (shared bucket) | 60 / minute | per key |
prepare-mint, per COLLECTION | 30 / 10s | per collection, shared with first-party traffic |
| SDK client-side limiter | 5 rps sustained, 10 burst | your process |
These are floors we publish, not ceilings we reserve: we will not lower them without telling you first, because a pinned client cannot be asked to redeploy. Raising them is not a breaking change.
Two sharing details that matter in practice:
prepareandexecuteshare the same 60/min bucket, and a mint needs one of each — so the sustained ceiling is roughly 30 mints per minute per key, not 60.- The read limiter is the same instance
/gravemint/publicuses, so v1 reads and first-party public reads from one IP draw on one 600/min bucket. - 🚨 The per-collection prepare cap is shared with gravemint.io. It is keyed on the collection alone, so on a hot drop your prepares compete with every first-party collector's — and on a busy launch it can bind well below the 60/min-per-key figure. Treat 60/min as a ceiling you may not reach, not a reservation.
The mint limit is keyed on the API key, not the IP, so a partner proxying every collector through one server is not sharing a bucket with the rest of the internet — but it does mean all of that partner's collectors share it.
On a 429 the SDK honours Retry-After and retries up to maxRetries. Disable the client-side limiter with rateLimit: null only if you have your own queue. If you need more headroom, ask — do not engineer around it.
Versioning
v1 is additive-only. Within the major we will not remove a field, change a field's type, rename an error code, or tighten validation. New behaviour arrives as new fields or a new version. Unversioned legacy paths keep working.
You can pin this package and leave it alone. That is the whole promise, and it is why capability negotiation exists — pinning keeps you working, and supportedBySurface keeps you correct.
await gm.v1.version(); // { version: "v1", stability: "additive-only", docs }x-gm-client is sent on every request so we can see which SDK versions are in use before we change anything. (It is not User-Agent: browsers forbid setting that header, so it would silently vanish in exactly the tier we most need to measure.)
Deprecations will be announced with a migration path and a real timeline, and old majors keep working while both are live.
Legacy read API
The original read-only surface is unchanged and still supported:
| Resource | Methods |
|---|---|
gm.discovery | getFeatured, getLiveMints, getTrending, getUpcoming, getRecentSoldouts, listCollections, searchCollections |
gm.collections | get, getGallery, getRecentlyMinted |
gm.nfts | get |
gm.bounty | getSummary, getPrizes |
gm.platform | getCapabilities, getTokenPrices |
gm.codes | validate (read-only — does not redeem) |
⚠️ These are not usable with a partner key, and mostly you do not want them. /gravemint/public sits behind the first-party origin check, so a partner origin is refused and the only origin-less credential it accepts is the shared platform secret, which partners are never issued.
The split is deliberate rather than incidental:
gm.discoverystays first-party. Featured, trending, upcoming, search andlistCollectionsare cross-collection by definition. A key issued for one drop enumerating every drop on the platform is exactly what the scoping model exists to prevent, so this is not a gap and will not be opened up.- Everything else a partner needs now has a scoped
gm.v1equivalent (gravemint#3348) —gallery,recentlyMinted,bounty,bountyPrizesandwalletMintsabove, pluscollectionandeligibility. Use those.
Use gm.v1 to build a mint page regardless: gm.collections.get() returns raw phase fields including prices that have not been through the resolver, so rendering a price from it can leak one a creator chose to hide.
Security notes for partners
- Register every origin, including staging and preview URLs. Origin binding is what makes a publishable key safe.
gm_live_never reaches a browser. No origin bounds it.- If a key leaks, tell us and we revoke it. Revocation is immediate, but it does not undo mints already made with it — we can tell you exactly what those were, because every mint records the key that made it.
- Never recompute a price or an eligibility verdict. Beyond being wrong, it moves a trust decision onto a surface we cannot verify.
- Never broadcast a transaction yourself. See the top of this document.
Development
npm test # unit tests, no network
npm run typecheck
npm run build # dual ESM + CJSLicense
MIT
