Design
A design is the white-label theme applied to your team's public passports — the pages a QR code resolves to. Colors, corner rounding, shadow depth, logo and font: everything a visitor sees that is yours rather than Synex's.
There is exactly one design per team, and it is addressed without an id. A key is pinned to one team and a team has one theme, so there is nothing for an id to disambiguate — /v1/design is the whole address.
The abilities are the whole gate
Neither design:read nor design:write is in a key's default ability set — grant them explicitly when you mint the key.
They are non-default for a reason worth stating plainly: a key minted to write passport values has no business restyling every public passport the team has ever published. One careless grant turns a data integration into something that can repaint the customer-facing surface of the whole product line.
There is deliberately no role gate on top of that. Any team member may edit branding in the Synex application, so requiring admin or owner here would make the API stricter than the surface it mirrors — and a rule that only exists on one of two equivalent paths is not a security boundary, just a difference to work around.
The design model
- Name
object- Type
- string
- Description
Always
design.
- Name
palette- Type
- array
- Description
An ordered color scale of
{ lightness, hex }entries.[]when unset. At most 32 entries; the application writes 11.
- Name
rounding- Type
- integer
- Description
Corner-rounding level, 1–6. Nullable.
- Name
shadow- Type
- integer
- Description
Shadow-depth level, 1–6. Nullable.
- Name
logo- Type
- string
- Description
A
file_id from your team's Drive. Nullable.
- Name
font- Type
- string
- Description
A font family name. Nullable.
palette is an ordered scale, not a bag of settings
The array's position carries meaning. The renderer reads index 5 as the brand color and index 0 as the page background; the rest fill in the shades between. Reordering the entries restyles the passport even though every color in it is unchanged, so treat the palette as a scale you author top to bottom rather than as a set of named options you can shuffle.
Each entry is {lightness, hex} — lightness on the familiar 50/100/200/…/900/950 scale, and hex as #RRGGBB.
rounding and shadow are levels, not measurements
Both are integers from 1 to 6, and both index the renderer's own class maps. rounding: 3 is not "3px" and shadow: 2 is not a blur radius — they select the third and second steps of scales the renderer defines. Move them a step at a time and look at the result; there is no unit to compute with.
An unset design reads as null, not as a default
A team that has never set a theme gets palette: [] and null for every scalar. Those nulls mean "not set", not "set to the default". The renderer's own fallbacks decide what an unthemed passport looks like, and reporting those fallbacks here would claim the team had picked something it never picked — which is exactly the claim that breaks the moment the renderer's defaults change.
If you are mirroring a design into your own system, carry the nulls through rather than substituting values for them.
logo is an id, not a link
GET /v1/design returns the logo's file id and stops there. To get the bytes, turn it into a link yourself with GET /v1/files/{file}/download.
A presigned URL is a bearer credential with a clock on it. Minting one on every read of the theme would hand you a signature you did not ask for, on every poll, that you could not cache for longer than its own short lifetime — and it would put a signing round-trip inside a call that is otherwise a single cheap read.
Retrieve the design
Reads the team's theme. Requires design:read.
The singleton always exists — a team that has never touched its branding still has a design, one whose every field is null — so this never 404s.
Request
curl https://api.synexcloud.com/v1/design \
-H "Authorization: Bearer $SYNEX_API_KEY"
Response
{
"object": "design",
"palette": [
{ "lightness": 50, "hex": "#F5F8FC" },
{ "lightness": 100, "hex": "#E7EEF8" },
{ "lightness": 200, "hex": "#C8D9F1" },
{ "lightness": 300, "hex": "#9CBCE6" },
{ "lightness": 400, "hex": "#6698DA" },
{ "lightness": 500, "hex": "#2F6FED" },
{ "lightness": 600, "hex": "#2459C4" },
{ "lightness": 700, "hex": "#1C459B" },
{ "lightness": 800, "hex": "#153472" },
{ "lightness": 900, "hex": "#0E2450" },
{ "lightness": 950, "hex": "#0A1836" }
],
"rounding": 3,
"shadow": 2,
"logo": "file_00a1b2c3d4e5f607",
"font": "Inter"
}
Unset
{
"object": "design",
"palette": [],
"rounding": null,
"shadow": null,
"logo": null,
"font": null
}
Update the design
Moves only the fields present in the body. Requires design:write. Omit a field and it keeps its stored value; there is no need to read the theme, edit it, and send it back whole.
palette is the exception: it replaces wholesale. Merging a caller's three entries into a stored eleven would splice two scales together and produce something nobody designed — a brand color from one palette sitting on a background from another. Send the entire scale, or leave the key out of the body entirely.
Clearing a field
logo and font accept an explicit null to unset them.
One quirk, documented because it will otherwise cost you an afternoon:
font: "" also clears it. The API converts empty strings in request
bodies to null globally, so an empty string does not round-trip — you
send "", and the next read returns null. Send null when you mean
"unset".
The logo must exist
A logo that does not name a file in this team's Drive is rejected with 400 resource_missing and param: "logo". Nothing is written.
It is checked because the failure it prevents is silent and public. A logo pointing at nothing renders as a broken image on every public passport the team publishes, and the write that caused it would have returned a 200 that looked exactly like success.
It is a 400 rather than a 404 because of what the request addressed: the /v1/design resource exists and was found; it is the logo parameter that names a missing object.
An empty body is an error
PATCH /v1/design with {} is 400 parameter_invalid, not a no-op success. A body naming no field is a caller mistake — a serializer that dropped its fields, a config object that came back empty — and a 200 would hide it behind a response that looks exactly like the change landing.
It also keeps the change feed honest: no design.updated is emitted for a request that changed nothing.
Optional attributes
- Name
palette- Type
- array
- Description
The whole color scale. Replaces, never merges.
- Name
rounding- Type
- integer
- Description
Corner-rounding level, 1–6.
- Name
shadow- Type
- integer
- Description
Shadow-depth level, 1–6.
- Name
logo- Type
- string
- Description
A
file_id from your team's Drive, ornullto clear.
- Name
font- Type
- string
- Description
A font family name, or
nullto clear.
Request
curl -X PATCH https://api.synexcloud.com/v1/design \
-H "Authorization: Bearer $SYNEX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"palette": [
{"lightness": 50, "hex": "#F5F8FC"},
{"lightness": 500, "hex": "#2F6FED"},
{"lightness": 950, "hex": "#0A1836"}
],
"rounding": 3
}'
Response
{
"object": "design",
"palette": [
{ "lightness": 50, "hex": "#F5F8FC" },
{ "lightness": 500, "hex": "#2F6FED" },
{ "lightness": 950, "hex": "#0A1836" }
],
"rounding": 3,
"shadow": 2,
"logo": "file_00a1b2c3d4e5f607",
"font": "Inter"
}
shadow, logo and font were absent from that body, so they kept whatever they held. The palette, being a palette, replaced the stored one entirely.
Events
A successful PATCH emits design.updated. Its payload is byte-identical to the response you just received, so a consumer on the change feed sees the same theme the caller does, with no follow-up read.
There is no design.created. The singleton always exists, so there is no moment of creation to report.