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
dataonce. You have the first page. Followlinks.next, or filter instead of scanning. -
Testing a write for exactly
200. A call that creates something answers201, so an equality check rejects your own successful first import. Accept any2xx. -
Ignoring
errorson a success. A2xxcan 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, a422that writes nothing at all.) -
Storing our
idas your key. Storeunique_id, which is the identity every write is matched on. -
Rebuilding the next page's URL by hand.
links.nextalready carries your filter and page size. Building?page=2yourself and forgetting the rest is how rows get skipped.