Startnext API — Examples

Runnable code for calling the Startnext API and for two complete integrations. For the endpoints, authentication, rate limits and error handling themselves, see the Developer Guide.

Code Examples

All three examples hold the API key and therefore belong on your server, not in a browser — see Keeping Your API Key Secret.

JavaScript (fetch)

// Server-side: Node.js 18 or newer, or any runtime that provides `fetch`.
const API_KEY = 'snx_proj_xxxxxxxx';
const PROJECT = 'my-project';

async function getProjectData() {
    const response = await fetch(
        `https://www.startnext.com/myty/api/crowdfunding/project/${PROJECT}`,
        {
            headers: {
                'Authorization': `Bearer ${API_KEY}`,
                'Accept': 'application/json'
            }
        }
    );

    if (!response.ok) {
        const error = await response.json();
        throw new Error(error.message || `HTTP ${response.status}`);
    }

    return response.json();
}

// Usage
getProjectData()
    .then(data => {
        console.log(`Funding: ${data.project.current_funding} €`);
        console.log(`Supporters: ${data.project.supporter_count}`);
    })
    .catch(error => console.error('Error:', error));

PHP (cURL)

<?php
$apiKey = 'snx_proj_xxxxxxxx';
$project = 'my-project';

function getProjectData($project, $apiKey) {
    $ch = curl_init();

    curl_setopt_array($ch, [
        CURLOPT_URL => "https://www.startnext.com/myty/api/crowdfunding/project/{$project}",
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => [
            "Authorization: Bearer {$apiKey}",
            "Accept: application/json"
        ]
    ]);

    $response = curl_exec($ch);
    $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($httpCode !== 200) {
        throw new Exception("API error: HTTP {$httpCode}");
    }

    return json_decode($response, true);
}

// Usage
try {
    $data = getProjectData($project, $apiKey);
    echo "Funding: " . $data['project']['current_funding'] . " €\n";
    echo "Supporters: " . $data['project']['supporter_count'] . "\n";
} catch (Exception $e) {
    echo "Error: " . $e->getMessage() . "\n";
}

Python (requests)

import requests

API_KEY = 'snx_proj_xxxxxxxx'
PROJECT = 'my-project'

def get_project_data(project, api_key):
    url = f'https://www.startnext.com/myty/api/crowdfunding/project/{project}'
    headers = {
        'Authorization': f'Bearer {api_key}',
        'Accept': 'application/json'
    }

    response = requests.get(url, headers=headers)
    response.raise_for_status()

    return response.json()

# Usage
try:
    data = get_project_data(PROJECT, API_KEY)
    print(f"Funding: {data['project']['current_funding']} €")
    print(f"Supporters: {data['project']['supporter_count']}")
except requests.exceptions.HTTPError as e:
    print(f"Error: {e}")

Usage Examples

Both examples call your own endpoint, never the Startnext API directly — the API key stays on your server. See Keeping Your API Key Secret for why, and the rewards example below for the backend half.

Display Funding Progress on Your Own Website

Show the current funding status of your project on your website:


<div id="funding-widget">
    <div class="progress-bar">
        <div class="progress" id="progress"></div>
    </div>
    <p id="funding-status"></p>
    <p><span id="supporters">0</span> supporters</p>
</div>

<script>
    async function updateWidget() {
        // Your own endpoint, backed by GET /project/my-project — see the rewards example below.
        const response = await fetch('/api/startnext-funding');
        const {project} = await response.json();

        // project_type_all_or_nothing: true  = funding goal must be reached
        // project_type_all_or_nothing: false = successful from the first support
        if (project.project_type_all_or_nothing && project.funding_threshold) {
            const percentage = (project.current_funding / project.funding_threshold) * 100;
            document.getElementById('progress').style.width = `${Math.min(percentage, 100)}%`;
            document.getElementById('funding-status').innerHTML =
                `<span>${project.current_funding.toFixed(2)}</span> ${project.currency} of ` +
                `<span>${project.funding_threshold.toFixed(2)}</span> ${project.currency} funded`;
        } else {
            // Project without funding goal
            document.getElementById('progress').style.width = '100%';
            document.getElementById('funding-status').innerHTML =
                `<span>${project.current_funding.toFixed(2)}</span> ${project.currency} raised`;
        }
        document.getElementById('supporters').textContent = project.supporter_count;
    }

    updateWidget();
    setInterval(updateWidget, 60000); // Refresh every 60 seconds
</script>

Display Rewards in Your Own Shop

Backend — holds the key, pages through everything, filters, caches, and hands out only the fields the page needs:

// server.js — Node.js 18 or newer, `npm install express`
import express from 'express';

const app = express();

const API_ROOT = 'https://www.startnext.com/myty/api/crowdfunding';
const PROJECT = process.env.STARTNEXT_PROJECT_SLUG;
const API_KEY = process.env.STARTNEXT_PROJECT_API_KEY;

if (!PROJECT || !API_KEY) {
    throw new Error('STARTNEXT_PROJECT_SLUG or STARTNEXT_PROJECT_API_KEY is missing');
}

let cache = null;
let cacheExpiresAt = 0;
let lastRateLimit = null;

function isPublic(reward) {
    if (reward.status !== 'enabled') return false;
    if (!reward.hidden_until_goal_reached) return true;

    return reward.is_unlocked === true;
}

function getPreviewImage(reward) {
    const galleryImage = reward.gallery_images?.find(file => file.is_image);

    return galleryImage?.thumbnails?.['2x']
        ?? galleryImage?.thumbnails?.['1x']
        ?? reward.image?.thumbnails?.['2x']
        ?? reward.image?.thumbnails?.['1x']
        ?? null;
}

function toPublicFields(reward) {
    const isLimited = reward.quantity_total > 0;

    return {
        id: reward.id,
        title: reward.title,
        // `description` contains HTML. This only removes the tags for plain-text output — it is not a
        // sanitizer. To keep the formatting and render HTML, use a real one (e.g. `sanitize-html`).
        description: String(reward.description ?? '').replace(/<[^>]*>/g, '').trim(),
        price: reward.price,
        currency: reward.project?.currency ?? 'EUR',
        // Outside a limited reward, `quantity_remaining` is a placeholder (999), not a stock figure.
        remaining: isLimited ? reward.quantity_remaining : null,
        available: reward.is_available,
        orderable: reward.is_unlocked !== false,
        // Only meaningful while `orderable` is false — a locked reward that `isPublic()` still let
        // through (see above) still owes the page the amount missing until unlock.
        fundingAmountUntilUnlock: reward.is_unlocked === false
            ? reward.funding_amount_until_unlock ?? null
            : null,
        // May be null — the card below simply renders without a picture then.
        imageUrl: getPreviewImage(reward),
        url: reward.url,
    };
}

// Read on every response, not only on 429 — this is what you would feed into monitoring to see a
// rate limit coming before it is hit. See "Rate Limiting" in the Developer Guide.
function readRateLimit(response) {
    return {
        limit: Number(response.headers.get('X-RateLimit-Minute-Limit')),
        remaining: Number(response.headers.get('X-RateLimit-Minute-Remaining')),
        retryAfter: Number(response.headers.get('Retry-After')),
    };
}

async function fetchAllRewards() {
    const rewards = [];
    const limit = 100;
    let offset = 0;

    while (true) {
        const response = await fetch(
            `${API_ROOT}/project/${encodeURIComponent(PROJECT)}/incentives` +
            `?limit=${limit}&offset=${offset}`,
            {headers: {Authorization: `Bearer ${API_KEY}`, Accept: 'application/json'}}
        );

        lastRateLimit = readRateLimit(response);

        if (response.status === 429) {
            // `Retry-After` is in seconds and always safe to retry after — see "Rate Limiting" in
            // the Developer Guide. Retries the same offset, so no page is skipped.
            const retryAfterSeconds = lastRateLimit.retryAfter || 1;
            await new Promise(resolve => setTimeout(resolve, retryAfterSeconds * 1000));
            continue;
        }

        if (!response.ok) {
            throw new Error(`Startnext API responded with HTTP ${response.status}`);
        }

        const {data, meta} = await response.json();
        rewards.push(...data);

        // Page by what you received, not by what you asked for.
        if (data.length === 0 || rewards.length >= meta.total) break;
        offset += data.length;
    }

    return rewards.filter(isPublic).map(toPublicFields);
}

app.get('/api/startnext-rewards', async (request, response) => {
    if (cache && Date.now() < cacheExpiresAt) {
        return response.json(cache);
    }

    try {
        cache = {rewards: await fetchAllRewards()};
        cacheExpiresAt = Date.now() + 60_000;

        return response.json(cache);
    } catch (error) {
        console.error('Could not load rewards from Startnext', {
            message: error.message,
            rateLimit: lastRateLimit,
        });

        // Keep the page populated through a short outage.
        if (cache) return response.json(cache);

        return response.status(502).json({error: 'Rewards are currently unavailable.'});
    }
});

// Variation groups (e.g. "Color", "Size") only exist on the single-reward endpoint, not on the
// list above — see "Get a Single Reward" in the Developer Guide. Fetched on demand per reward
// (not upfront for the whole grid) to avoid one extra request per card.
async function fetchRewardVariations(rewardId) {
    const response = await fetch(
        `${API_ROOT}/project/${encodeURIComponent(PROJECT)}/incentive/${encodeURIComponent(rewardId)}`,
        {headers: {Authorization: `Bearer ${API_KEY}`, Accept: 'application/json'}}
    );

    if (!response.ok) {
        throw new Error(`Startnext API responded with HTTP ${response.status}`);
    }

    const {data: reward} = await response.json();

    // A group with no label is unused. Empty choices means free text (e.g. a custom engraving)
    // instead of a fixed pick list. The API's own field names keep the `variant_` prefix; only
    // our own naming below switches to "variation".
    return [1, 2, 3]
        .map(group => ({
            label: reward[`variant_label_${group}`],
            choices: reward[`variant_choices_${group}`],
        }))
        .filter(variation => variation.label !== null);
}

app.get('/api/startnext-rewards/:id/variations', async (request, response) => {
    try {
        const variations = await fetchRewardVariations(request.params.id);
        return response.json({variations});
    } catch (error) {
        console.error(error);
        return response.status(502).json({error: 'Reward options are currently unavailable.'});
    }
});

app.listen(3000);

Browser — reads your endpoint and builds the cards with textContent, so no field can inject markup. Each orderable reward gets a "Preview options" button that lazily loads its variation groups (color, size, a free-text engraving field, …) — this only previews the choices; picking one and actually pledging still happens on Startnext after "Support now":

// Renders the fetched variation groups into `container` — a <select> per group with fixed
// choices, a text <input> for a free-text group (empty `choices`), or a note if the reward has
// none at all.
function renderVariations(container, variations) {
    container.replaceChildren();

    if (variations.length === 0) {
        container.textContent = 'This reward has no options to choose from.';
        return;
    }

    for (const variation of variations) {
        const field = document.createElement('div');
        field.className = 'variation-field';

        const label = document.createElement('label');
        label.textContent = variation.label;
        field.append(label);

        if (variation.choices.length > 0) {
            const select = document.createElement('select');
            for (const choice of variation.choices) {
                const option = document.createElement('option');
                option.value = String(choice.id);
                option.textContent = choice.name;
                select.append(option);
            }
            field.append(select);
        } else {
            const input = document.createElement('input');
            input.type = 'text';
            input.placeholder = 'Your own text';
            field.append(input);
        }

        container.append(field);
    }
}

async function previewVariations(rewardId, container) {
    container.textContent = 'Loading options …';

    try {
        const response = await fetch(`/api/startnext-rewards/${encodeURIComponent(rewardId)}/variations`);
        const {variations} = await response.json();
        renderVariations(container, variations);
    } catch (error) {
        console.error(error);
        container.textContent = 'Options could not be loaded.';
    }
}

async function loadRewards() {
    const response = await fetch('/api/startnext-rewards');
    const {rewards} = await response.json();

    document.getElementById('rewards').replaceChildren(...rewards.map(reward => {
        const card = document.createElement('article');
        card.className = 'reward-card';

        // Rewards without a picture stay valid cards — no placeholder needed.
        if (reward.imageUrl) {
            const image = document.createElement('img');
            image.className = 'reward-image';
            image.src = reward.imageUrl;
            image.alt = '';
            image.loading = 'lazy';
            card.append(image);
        }

        const title = document.createElement('h3');
        title.textContent = reward.title;

        const description = document.createElement('p');
        description.textContent = reward.description;

        const price = document.createElement('p');
        price.className = 'price';
        price.textContent = new Intl.NumberFormat('de-DE', {
            style: 'currency',
            currency: reward.currency,
        }).format(reward.price);

        card.append(title, description, price);

        if (reward.remaining !== null) {
            const stock = document.createElement('p');
            stock.className = 'stock';
            stock.textContent = `${reward.remaining} remaining`;
            card.append(stock);
        }

        // A reward locked behind a funding threshold is still a card, not a gap in the grid — the
        // backend already dropped the ones that must stay secret (see `isPublic()`); this is what
        // Startnext's own project page shows for the rest: the amount still missing.
        if (!reward.orderable && reward.fundingAmountUntilUnlock !== null) {
            const locked = document.createElement('p');
            locked.className = 'reward-locked';
            locked.textContent = `${new Intl.NumberFormat('de-DE', {
                style: 'currency',
                currency: reward.currency,
            }).format(reward.fundingAmountUntilUnlock)} until unlock`;
            card.append(locked);
        }

        // Preview only — variation groups aren't in the list response, so this loads them lazily
        // and on demand, not upfront for every card in the grid.
        if (reward.available && reward.orderable) {
            const variationsPreview = document.createElement('div');
            variationsPreview.className = 'variations-preview';

            const previewButton = document.createElement('button');
            previewButton.type = 'button';
            previewButton.className = 'variations-preview-toggle';
            previewButton.textContent = 'Preview options';
            previewButton.addEventListener('click', () => {
                previewVariations(reward.id, variationsPreview);
            }, {once: true});

            card.append(previewButton, variationsPreview);
        }

        const link = document.createElement('a');
        link.className = 'button';
        link.href = reward.url;
        link.textContent = reward.available && reward.orderable
            ? 'Support now'
            : 'View on Startnext';
        card.append(link);

        return card;
    }));
}

CSS — targets the class names set by the two snippets above; the container itself (<div id="rewards" class="rewards-grid"></div>) is the one place your own markup needs to match:

.rewards-grid {
    display: grid;
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: 24px;
}

.reward-card {
    display: flex;
    flex-direction: column;
    overflow: hidden;
    border: 1px solid #ddd;
    border-radius: 12px;
    background: #fff;
}

.reward-image {
    width: 100%;
    aspect-ratio: 3 / 2;
    object-fit: cover;
}

.reward-card h3,
.reward-card p {
    margin: 0 16px 8px;
}

.price {
    margin-top: auto;
    font-size: 1.25rem;
    font-weight: bold;
}

.stock {
    font-size: 0.875rem;
    color: #555;
}

/* Same slot as `.stock` — a reward is either counted down or locked, never both. */
.reward-locked {
    font-size: 0.875rem;
    font-weight: bold;
    color: #8a5a00;
}

.variations-preview-toggle {
    margin: 0 16px 8px;
}

.variation-field {
    margin: 0 16px 8px;
}

.variation-field select,
.variation-field input {
    width: 100%;
}

.button {
    display: block;
    margin: 8px 16px 16px;
    padding: 10px 16px;
    border-radius: 999px;
    background: #d54e3f;
    color: #fff;
    text-align: center;
    text-decoration: none;
}

@media (max-width: 900px) {
    .rewards-grid {
        grid-template-columns: repeat(2, minmax(0, 1fr));
    }
}

@media (max-width: 600px) {
    .rewards-grid {
        grid-template-columns: 1fr;
    }
}