API / Guides

Example scripts

Runnable read, find and write scripts in five languages.

Complete, runnable versions of the three things nearly every integration does: read the whole component list, find one component, and write back. Each is shown in every language the rest of these docs use, plus Google Apps Script, since a lot of designers keep their card data in a Google Sheet.

Read this first: a list is a page, not a project.

GET /components returns at most 50 rows by default and 200 at the very most. Read data once and you have the first page and nothing else, with a 200 and a full-looking array to tell you everything went fine. Searching that array for a component and concluding it does not exist is the single most common mistake made against this API. Follow links.next until it is null, or do not scan at all and filter for the one row you want.

1. Read every component

The loop is the whole lesson. Ask for the largest page you are allowed (per_page=200), then keep following links.next until it comes back null. That link carries your own filter and page size forward, so you never have to rebuild the URL yourself, and meta.total is there if you want to check your count at the end.

TOKEN="paste_your_token"
PROJECT=42
URL="https://dev.dustinsdesignerden.com/api/v1/projects/$PROJECT/components?per_page=200"

# links.next is null on the last page, so the loop ends on its own.
while [ -n "$URL" ] && [ "$URL" != "null" ]; do
  BODY=$(curl -s -H "Authorization: Bearer $TOKEN" "$URL")
  echo "$BODY" | jq -r '.data[] | .unique_id + "  " + .name'
  URL=$(echo "$BODY" | jq -r '.links.next')
done
const TOKEN = process.env.DDD_TOKEN;

async function allComponents(projectId) {
  const out = [];
  let url = `https://dev.dustinsdesignerden.com/api/v1/projects/${projectId}/components?per_page=200`;

  while (url) {
    const res = await fetch(url, {
      headers: { Authorization: `Bearer ${TOKEN}`, Accept: 'application/json' },
    });

    if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);

    const body = await res.json();
    out.push(...body.data);

    // Follow the link the API hands back. It carries your filter and page size,
    // and is null once there is nothing left.
    url = body.links.next;
  }

  return out;
}

const components = await allComponents(42);
console.log(`${components.length} components`);
const TOKEN = 'paste_your_token';

function allComponents(projectId) {
  const out = [];
  let url = 'https://dev.dustinsdesignerden.com/api/v1/projects/' + projectId + '/components?per_page=200';

  while (url) {
    const res = UrlFetchApp.fetch(url, {
      method: 'get',
      headers: { Authorization: 'Bearer ' + TOKEN, Accept: 'application/json' },
      muteHttpExceptions: true,
    });

    if (res.getResponseCode() !== 200) {
      throw new Error(res.getResponseCode() + ': ' + res.getContentText());
    }

    const body = JSON.parse(res.getContentText());
    out.push(...body.data);

    // null once there is nothing left, which is what ends the loop.
    url = body.links.next;
  }

  return out;
}

function logEveryComponent() {
  const components = allComponents(42);
  Logger.log(components.length + ' components');
}
$token = getenv('DDD_TOKEN');

function ddd_get(string $url, string $token): array
{
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => [
            'Authorization: Bearer '.$token,
            'Accept: application/json',
        ],
    ]);

    $body = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($status < 200 || $status > 299) {
        throw new RuntimeException($status.': '.$body);
    }

    return json_decode($body, true);
}

$url = 'https://dev.dustinsdesignerden.com/api/v1/projects/42/components?per_page=200';
$all = [];

while ($url !== null) {
    $page = ddd_get($url, $token);
    $all = array_merge($all, $page['data']);

    // Null on the last page.
    $url = $page['links']['next'];
}

echo count($all).' components'.PHP_EOL;
import os
import requests

TOKEN = os.environ["DDD_TOKEN"]


def all_components(project_id):
    out = []
    url = f"https://dev.dustinsdesignerden.com/api/v1/projects/{project_id}/components?per_page=200"

    while url:
        res = requests.get(url, headers={
            "Authorization": f"Bearer {TOKEN}",
            "Accept": "application/json",
        })
        res.raise_for_status()

        body = res.json()
        out.extend(body["data"])

        # None on the last page.
        url = body["links"]["next"]

    return out


components = all_components(42)
print(len(components), "components")

At 200 rows a page, a 1,000-component project is five calls, well inside the read limit of 300 per minute.

2. Find one component

Do not page through the project looking for a row. Ask for it by name with ?name=, or by your own id with ?unique_id=. Both match exactly, both return the row wherever it sits in the ordering, and both leave you with an answer you can trust: an empty data array here really does mean the component is not in the project.

curl -s -G "https://dev.dustinsdesignerden.com/api/v1/projects/42/components" \
  -H "Authorization: Bearer $TOKEN" \
  --data-urlencode "name=Rusty Dagger" | jq '.data[0]'
async function findByName(projectId, name) {
  const url = `https://dev.dustinsdesignerden.com/api/v1/projects/${projectId}/components`
            + `?name=${encodeURIComponent(name)}`;

  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${TOKEN}`, Accept: 'application/json' },
  });

  if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);

  // Exact match, so normally one row. An empty data array really does mean
  // "no component by that name in this project".
  const [match] = (await res.json()).data;

  return match ?? null;
}

const card = await findByName(42, 'Rusty Dagger');
console.log(card ? card.unique_id : 'not in project');
function findByName(projectId, name) {
  const url = 'https://dev.dustinsdesignerden.com/api/v1/projects/' + projectId
            + '/components?name=' + encodeURIComponent(name);

  const res = UrlFetchApp.fetch(url, {
    method: 'get',
    headers: { Authorization: 'Bearer ' + TOKEN, Accept: 'application/json' },
    muteHttpExceptions: true,
  });

  if (res.getResponseCode() !== 200) {
    throw new Error(res.getResponseCode() + ': ' + res.getContentText());
  }

  // Exact match, so normally one row. An empty data array really does mean
  // "no component by that name in this project".
  return JSON.parse(res.getContentText()).data[0] || null;
}
$url = 'https://dev.dustinsdesignerden.com/api/v1/projects/42/components?name='.rawurlencode('Rusty Dagger');

// Exact match, so normally one row. An empty data array really does mean
// "no component by that name in this project".
$card = ddd_get($url, $token)['data'][0] ?? null;

echo $card ? $card['unique_id'] : 'not in project';
res = requests.get(
    "https://dev.dustinsdesignerden.com/api/v1/projects/42/components",
    headers={"Authorization": f"Bearer {TOKEN}", "Accept": "application/json"},
    params={"name": "Rusty Dagger"},
)
res.raise_for_status()

# Exact match, so normally one row. An empty data list really does mean
# "no component by that name in this project".
card = next(iter(res.json()["data"]), None)

print(card["unique_id"] if card else "not in project")

?name= is the same comparison a write uses for match_by_name, so it finds precisely the row a batch sending that name would update. Add ?type=card to narrow further if two types share a name.

3. What to keep, and what to send back

Keep your own key against each component and send it as unique_id on every write. The first call creates the component and every later call with that id updates it in place, which is what makes a re-sync safe to run as often as you like.

unique_id Store it Your key, not ours. Send whatever is stable on your side, such as a sheet row id. If you send nothing we generate one, and it is never null on a read, so you can always pick it back up from a listing.
id Do not key on it Our internal row id, returned for reference only. Nothing you send is matched on it, and the delete endpoint does not accept it.
name Treat as data Fine to look a component up by, but the designer can rename it in the Builder at any time and two components are allowed to share a name.
project.id Store it The {project} in nearly every path. Read it once from GET /projects and keep it.
curl -X POST "https://dev.dustinsdesignerden.com/api/v1/projects/42/components/batch" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "card",
    "components": [
      { "unique_id": "sheet-row-12", "name": "Rusty Dagger", "quantity": 3 }
    ]
  }'
const res = await fetch('https://dev.dustinsdesignerden.com/api/v1/projects/42/components/batch', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${TOKEN}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    type: 'card',
    components: [
      // unique_id is YOUR key. Row 12 of your sheet, every time.
      { unique_id: 'sheet-row-12', name: 'Rusty Dagger', quantity: 3 },
    ],
  }),
});

// A create answers 201 and an update answers 200. Both are success.
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);

const result = await res.json();

// Present even on a 2xx: valid rows still applied, so read it every time.
if (result.errors.length) console.warn(result.errors);
const res = UrlFetchApp.fetch('https://dev.dustinsdesignerden.com/api/v1/projects/42/components/batch', {
  method: 'post',
  contentType: 'application/json',
  headers: { Authorization: 'Bearer ' + TOKEN },
  muteHttpExceptions: true,
  payload: JSON.stringify({
    type: 'card',
    components: [
      // unique_id is YOUR key. Row 12 of your sheet, every time.
      { unique_id: 'sheet-row-12', name: 'Rusty Dagger', quantity: 3 },
    ],
  }),
});

// A create answers 201 and an update answers 200. Both are success, so a
// check for exactly 200 rejects your own successful first import.
const code = res.getResponseCode();
if (code < 200 || code > 299) {
  throw new Error(code + ': ' + res.getContentText());
}

const result = JSON.parse(res.getContentText());

// Present even on a 2xx: valid rows still applied, so read it every time.
if (result.errors.length) Logger.log(result.errors);
$payload = json_encode([
    'type' => 'card',
    'components' => [
        // unique_id is YOUR key. Row 12 of your sheet, every time.
        ['unique_id' => 'sheet-row-12', 'name' => 'Rusty Dagger', 'quantity' => 3],
    ],
]);

$ch = curl_init('https://dev.dustinsdesignerden.com/api/v1/projects/42/components/batch');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => $payload,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer '.$token,
        'Content-Type: application/json',
    ],
]);

$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

// A create answers 201 and an update answers 200. Both are success.
if ($status < 200 || $status > 299) {
    throw new RuntimeException($status.': '.$body);
}

$result = json_decode($body, true);

// Present even on a 2xx: valid rows still applied, so read it every time.
if ($result['errors'] !== []) {
    print_r($result['errors']);
}
res = requests.post(
    "https://dev.dustinsdesignerden.com/api/v1/projects/42/components/batch",
    headers={"Authorization": f"Bearer {TOKEN}"},
    json={
        "type": "card",
        "components": [
            # unique_id is YOUR key. Row 12 of your sheet, every time.
            {"unique_id": "sheet-row-12", "name": "Rusty Dagger", "quantity": 3},
        ],
    },
)

# A create answers 201 and an update answers 200. Both are success.
res.raise_for_status()

result = res.json()

# Present even on a 2xx: valid rows still applied, so read it every time.
if result["errors"]:
    print(result["errors"])

Up to 500 rows and one type per call. See Syncing components for the full write path, including remove_missing.

Mistakes worth checking for

  • Reading data once. You have the first page. Follow links.next, or filter instead of scanning.
  • Testing a write for exactly 200. A call that creates something answers 201, so an equality check rejects your own successful first import. Accept any 2xx.
  • Ignoring errors on a success. A 2xx can still carry per-row problems: a card that was written but could not join the deck you named, for instance. The write landed; part of what you asked for did not. (A row that fails validation is different and loud, a 422 that writes nothing at all.)
  • Storing our id as your key. Store unique_id, which is the identity every write is matched on.
  • Rebuilding the next page's URL by hand. links.next already carries your filter and page size. Building ?page=2 yourself and forgetting the rest is how rows get skipped.