Vestaboard Note Arrays
Multiple Vestaboard Notes can be enabled for the Local API independently and arranged together to display a single large message spanning all of them.
Overview
Each Vestaboard Note has a 3×15 character grid. By positioning several Notes side-by-side or stacked, you can create larger display surfaces:
| Arrangement | Vestaboard Notes | Effective grid |
|---|---|---|
| 2 side-by-side | 2 | 3 × 30 |
| 4 side-by-side | 4 | 3 × 60 |
| 2 stacked | 2 | 6 × 15 |
| 4 stacked | 4 | 12 × 15 |
| 2×2 grid | 4 | 6 × 30 |
There is no central endpoint that broadcasts to multiple Vestaboard Notes at once. Instead, you manage the layout yourself — a local script or server slices the full message into per-board regions and sends each slice to the appropriate Vestaboard Note.
Enabling the Local API on Each Vestaboard Note
Each Vestaboard Note must be enabled individually using its own enablement token. Repeat the enablement step for every Vestaboard Note in your array, saving each API key alongside the IP address of that board.
See Endpoints for the full enablement flow.
The X-Vestaboard-Local-Api-Key header used in the code examples below is the apiKey value returned by the enablement endpoint — not the one-time enablement token. The key does not expire. See Authentication for the full enablement flow.
Finding Each Vestaboard Note's IP Address
Each Vestaboard Note announces itself on your local network via mDNS. The first Vestaboard Note you connect responds to vestaboard.local, the second to vestaboard-2.local, and so on. You can verify connectivity with:
ping vestaboard.local
ping vestaboard-2.local
If mDNS is unavailable on your network, check your router's DHCP table or look up each board's IP in the Vestaboard app under device settings. Assigning static DHCP leases by MAC address (visible in the app under Advanced Settings) keeps the mapping stable across reboots.
Character Codes
Each cell in the message array is an integer representing a character. 0 is blank (black background). Letters, digits, punctuation, and color tiles each have their own code.
See the Character Code Reference for the full table.
How Many Vestaboard Notes Can You Use?
Any number of Vestaboard Notes can form an array — the slicing logic generalizes to 3, 5, 6, or any count. The overview table shows common configurations, but you are not limited to 2 or 4. Simply define one entry per Vestaboard Note with its IP, API key, and offset into the full message.
Slicing and Sending the Message
For a 4-Vestaboard-Note side-by-side array (3×60 total), split the full character array into four 3×15 slices and send each to the corresponding Vestaboard Note.
- Javascript
- Python
// Full message for a 4-Vestaboard-Note side-by-side array (3 rows × 60 columns)
const fullMessage = [
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
];
// One entry per Vestaboard Note: its IP address and its starting column in the full message
const notes = [
{ host: "http://192.168.1.101:7000", apiKey: "KEY_1", colOffset: 0 },
{ host: "http://192.168.1.102:7000", apiKey: "KEY_2", colOffset: 15 },
{ host: "http://192.168.1.103:7000", apiKey: "KEY_3", colOffset: 30 },
{ host: "http://192.168.1.104:7000", apiKey: "KEY_4", colOffset: 45 },
];
const NOTE_COLS = 15;
async function sendToNoteArray(fullMessage, notes) {
await Promise.all(
notes.map(({ host, apiKey, colOffset }) => {
const slice = fullMessage.map((row) =>
row.slice(colOffset, colOffset + NOTE_COLS)
);
return fetch(`${host}/local-api/message`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Vestaboard-Local-Api-Key": apiKey,
},
body: JSON.stringify(slice),
});
})
);
}
await sendToNoteArray(fullMessage, notes);
import requests
from concurrent.futures import ThreadPoolExecutor
# Full message for a 4-Vestaboard-Note side-by-side array (3 rows × 60 columns)
full_message = [
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
]
# One entry per Vestaboard Note: its IP address and its starting column in the full message
notes = [
{"host": "http://192.168.1.101:7000", "api_key": "KEY_1", "col_offset": 0},
{"host": "http://192.168.1.102:7000", "api_key": "KEY_2", "col_offset": 15},
{"host": "http://192.168.1.103:7000", "api_key": "KEY_3", "col_offset": 30},
{"host": "http://192.168.1.104:7000", "api_key": "KEY_4", "col_offset": 45},
]
NOTE_COLS = 15
def send_to_note(note, full_message):
offset = note["col_offset"]
slice_ = [row[offset : offset + NOTE_COLS] for row in full_message]
requests.post(
f"{note['host']}/local-api/message",
json=slice_,
headers={"X-Vestaboard-Local-Api-Key": note["api_key"]},
)
with ThreadPoolExecutor() as executor:
executor.map(lambda n: send_to_note(n, full_message), notes)
Stacked Arrangement
For Vestaboard Notes stacked vertically (e.g. 4 stacked = 12×15), split by row offset instead of column offset.
- Javascript
- Python
// Full message for 4 stacked Vestaboard Notes (12 rows × 15 columns)
const fullMessage = [
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
];
const notes = [
{ host: "http://192.168.1.101:7000", apiKey: "KEY_1", rowOffset: 0 },
{ host: "http://192.168.1.102:7000", apiKey: "KEY_2", rowOffset: 3 },
{ host: "http://192.168.1.103:7000", apiKey: "KEY_3", rowOffset: 6 },
{ host: "http://192.168.1.104:7000", apiKey: "KEY_4", rowOffset: 9 },
];
const NOTE_ROWS = 3;
async function sendToStackedNotes(fullMessage, notes) {
await Promise.all(
notes.map(({ host, apiKey, rowOffset }) => {
const slice = fullMessage.slice(rowOffset, rowOffset + NOTE_ROWS);
return fetch(`${host}/local-api/message`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Vestaboard-Local-Api-Key": apiKey,
},
body: JSON.stringify(slice),
});
})
);
}
await sendToStackedNotes(fullMessage, notes);
import requests
from concurrent.futures import ThreadPoolExecutor
# Full message for 4 stacked Vestaboard Notes (12 rows × 15 columns)
full_message = [
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
]
notes = [
{"host": "http://192.168.1.101:7000", "api_key": "KEY_1", "row_offset": 0},
{"host": "http://192.168.1.102:7000", "api_key": "KEY_2", "row_offset": 3},
{"host": "http://192.168.1.103:7000", "api_key": "KEY_3", "row_offset": 6},
{"host": "http://192.168.1.104:7000", "api_key": "KEY_4", "row_offset": 9},
]
NOTE_ROWS = 3
def send_to_note(note, full_message):
offset = note["row_offset"]
slice_ = full_message[offset : offset + NOTE_ROWS]
requests.post(
f"{note['host']}/local-api/message",
json=slice_,
headers={"X-Vestaboard-Local-Api-Key": note["api_key"]},
)
with ThreadPoolExecutor() as executor:
executor.map(lambda n: send_to_note(n, full_message), notes)
2×2 Grid Arrangement
For a 2×2 grid (6 rows × 30 columns), each Vestaboard Note gets a slice defined by both a row offset and a column offset.
┌──────────────┬──────────────┐
│ Board 1 │ Board 2 │
│ rows 0–2 │ rows 0–2 │
│ cols 0–14 │ cols 15–29 │
├──────────────┼──────────────┤
│ Board 3 │ Board 4 │
│ rows 3–5 │ rows 3–5 │
│ cols 0–14 │ cols 15–29 │
└──────────────┴──────────────┘
- Javascript
- Python
// Full message for a 2×2 grid (6 rows × 30 columns)
const fullMessage = [
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
];
// Each Vestaboard Note has both a row offset and a column offset
const notes = [
{ host: "http://192.168.1.101:7000", apiKey: "KEY_1", rowOffset: 0, colOffset: 0 },
{ host: "http://192.168.1.102:7000", apiKey: "KEY_2", rowOffset: 0, colOffset: 15 },
{ host: "http://192.168.1.103:7000", apiKey: "KEY_3", rowOffset: 3, colOffset: 0 },
{ host: "http://192.168.1.104:7000", apiKey: "KEY_4", rowOffset: 3, colOffset: 15 },
];
const NOTE_ROWS = 3;
const NOTE_COLS = 15;
async function sendToGridNotes(fullMessage, notes) {
await Promise.all(
notes.map(({ host, apiKey, rowOffset, colOffset }) => {
const slice = fullMessage
.slice(rowOffset, rowOffset + NOTE_ROWS)
.map((row) => row.slice(colOffset, colOffset + NOTE_COLS));
return fetch(`${host}/local-api/message`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Vestaboard-Local-Api-Key": apiKey,
},
body: JSON.stringify(slice),
});
})
);
}
await sendToGridNotes(fullMessage, notes);
import requests
from concurrent.futures import ThreadPoolExecutor
# Full message for a 2×2 grid (6 rows × 30 columns)
full_message = [
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
]
# Each Vestaboard Note has both a row offset and a column offset
notes = [
{"host": "http://192.168.1.101:7000", "api_key": "KEY_1", "row_offset": 0, "col_offset": 0},
{"host": "http://192.168.1.102:7000", "api_key": "KEY_2", "row_offset": 0, "col_offset": 15},
{"host": "http://192.168.1.103:7000", "api_key": "KEY_3", "row_offset": 3, "col_offset": 0},
{"host": "http://192.168.1.104:7000", "api_key": "KEY_4", "row_offset": 3, "col_offset": 15},
]
NOTE_ROWS = 3
NOTE_COLS = 15
def send_to_note(note, full_message):
row_offset = note["row_offset"]
col_offset = note["col_offset"]
slice_ = [
row[col_offset : col_offset + NOTE_COLS]
for row in full_message[row_offset : row_offset + NOTE_ROWS]
]
requests.post(
f"{note['host']}/local-api/message",
json=slice_,
headers={"X-Vestaboard-Local-Api-Key": note["api_key"]},
)
with ThreadPoolExecutor() as executor:
executor.map(lambda n: send_to_note(n, full_message), notes)
Tips
- Discover IP addresses — each Vestaboard Note's IP appears in your router's DHCP table or in the Vestaboard app under device settings. Using static DHCP leases (assigned by MAC address) keeps the mapping stable.
- Send in parallel — use
Promise.all(JavaScript) or a thread pool (Python) so all Vestaboard Notes flip at the same time and the display looks synchronized. - Check firmware — all Vestaboard Notes in an array should be on the same firmware version to ensure consistent timing behavior.