API Automation with curl and jq
This tutorial covers patterns for scripting Alloy programmatically using curl
and jq. You will learn how to capture IDs, chain commands, perform bulk
operations, and integrate Alloy into CI/CD pipelines.
Prerequisites
- A running Alloy server (see Getting Started)
curlandjqinstalled- A terminal with shell variable support (
bashorzsh)
Set variables used throughout this guide:
BASE_URL="http://localhost:3000"
1. Capturing IDs
Most Alloy API responses return JSON objects containing id fields. Use jq to
extract and store them in shell variables for subsequent requests.
Extract a single field:
USER_ID=$(curl -s -X POST "$BASE_URL/api/v1/auth/register" \
-H "Content-Type: application/json" \
-d '{
"email": "auto@alloy.dev",
"password": "automation1",
"display_name": "Automation User"
}' | jq -r '.user_id')
echo "$USER_ID"
Output:
550e8400-e29b-41d4-a716-446655440000
Extract multiple fields at once:
eval "$(curl -s -X POST "$BASE_URL/api/v1/auth/register" \
-H "Content-Type: application/json" \
-d '{
"email": "auto2@alloy.dev",
"password": "automation1",
"display_name": "Automation User 2"
}' | jq -r '@sh "TOKEN=\(.access_token) USER_ID=\(.user_id)"')"
echo "Token: $TOKEN"
echo "User: $USER_ID"
The register response contains the fields you need:
Token: eyJ...
User: 550e8400-...
Extract an ID from a list response:
FIRST_PROJECT_ID=$(curl -s "$BASE_URL/api/v1/projects?org_id=$ORG_ID" \
-H "Authorization: Bearer $TOKEN" | jq -r '.items[0].id')
echo "$FIRST_PROJECT_ID"
Output:
990e8400-e29b-41d4-a716-446655440000
2. Chaining Commands
Build complete workflows by piping the output of one command into the next. This example performs a full flow: onboard, create a project, and create a ticket.
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="http://localhost:3000"
# Step 1: Onboard — create the first org and admin user
ONBOARD=$(curl -s -X POST "$BASE_URL/api/v1/onboard" \
-H "Content-Type: application/json" \
-d '{
"email": "admin@acme.dev",
"password": "changeme123",
"org_name": "Acme Corp",
"org_slug": "acme"
}')
ORG_ID=$(echo "$ONBOARD" | jq -r '.org_id')
API_KEY=$(echo "$ONBOARD" | jq -r '.api_key')
USER_ID=$(echo "$ONBOARD" | jq -r '.user_id')
echo "Org: $ORG_ID"
echo "Key: $API_KEY"
# Step 2: Login to get a bearer token
TOKEN=$(curl -s -X POST "$BASE_URL/api/v1/auth/login" \
-H "Content-Type: application/json" \
-d '{
"email": "admin@acme.dev",
"password": "changeme123"
}' | jq -r '.access_token')
echo "Token: $TOKEN"
# Step 3: Create a project
PROJECT_ID=$(curl -s -X POST "$BASE_URL/api/v1/projects" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"org_id\": \"$ORG_ID\",
\"key\": \"AUTO\",
\"name\": \"Automation Project\"
}" | jq -r '.id')
echo "Project: $PROJECT_ID"
# Step 4: Create a ticket in the project
TICKET_ID=$(curl -s -X POST "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"title\": \"First automated ticket\",
\"description\": \"Created by the chaining script\",
\"priority\": \"High\",
\"reporter_id\": \"$USER_ID\"
}" | jq -r '.id')
echo "Ticket: $TICKET_ID"
# Step 5: Transition the ticket to InProgress
curl -s -X POST "$BASE_URL/api/v1/tickets/$TICKET_ID/transition" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"to_status": "InProgress"}' | jq .
{
"id": "aa0e8400-...",
"title": "First automated ticket",
"status": "InProgress",
"priority": "High"
}
3. Bulk Operations
Create tickets from a shell loop
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="http://localhost:3000"
# TOKEN, PROJECT_ID, USER_ID set from prior steps
TITLES=("Set up CI pipeline" "Write unit tests" "Update README" "Add logging" "Deploy to staging")
for title in "${TITLES[@]}"; do
TICKET_ID=$(curl -s -X POST "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"title\": \"$title\",
\"priority\": \"Medium\",
\"reporter_id\": \"$USER_ID\"
}" | jq -r '.id')
echo "Created ticket $TICKET_ID: $title"
done
{
"id": "bb0e8400-...",
"title": "Set up CI pipeline",
"status": "Backlog",
"priority": "Medium"
}
Create tickets from a text file
Given a file tickets.txt with one title per line:
Fix login timeout
Add password reset flow
Update error messages
Refactor auth middleware
Add rate limiting
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="http://localhost:3000"
# TOKEN, PROJECT_ID, USER_ID set from prior steps
while IFS= read -r title; do
[ -z "$title" ] && continue
RESULT=$(curl -s -X POST "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"title\": \"$title\",
\"priority\": \"Medium\",
\"reporter_id\": \"$USER_ID\"
}")
echo "$RESULT" | jq '{id: .id, title: .title, ticket_number: .ticket_number}'
done < tickets.txt
{
"id": "cc0e8400-...",
"title": "Fix login timeout",
"ticket_number": 6
}
4. Shell Helpers
Wrap common operations in reusable functions. Add these to your shell profile or source them from a script:
#!/usr/bin/env bash
# alloy-helpers.sh — source this file to use the helper functions
# Base configuration
ALLOY_BASE_URL="${ALLOY_BASE_URL:-http://localhost:3000}"
ALLOY_TOKEN="${ALLOY_TOKEN:-}"
alloy_get() {
local path="$1"
shift
curl -s "$ALLOY_BASE_URL$path" \
-H "Authorization: Bearer $ALLOY_TOKEN" \
"$@"
}
alloy_post() {
local path="$1"
local data="$2"
shift 2
curl -s -X POST "$ALLOY_BASE_URL$path" \
-H "Authorization: Bearer $ALLOY_TOKEN" \
-H "Content-Type: application/json" \
-d "$data" \
"$@"
}
alloy_patch() {
local path="$1"
local data="$2"
shift 2
curl -s -X PATCH "$ALLOY_BASE_URL$path" \
-H "Authorization: Bearer $ALLOY_TOKEN" \
-H "Content-Type: application/json" \
-d "$data" \
"$@"
}
alloy_delete() {
local path="$1"
shift
curl -s -X DELETE "$ALLOY_BASE_URL$path" \
-H "Authorization: Bearer $ALLOY_TOKEN" \
"$@"
}
Usage examples:
source alloy-helpers.sh
export ALLOY_TOKEN="eyJ..."
export ALLOY_BASE_URL="http://localhost:3000"
# List projects
alloy_get "/api/v1/projects?org_id=$ORG_ID" | jq '.items[].name'
# Create a ticket
alloy_post "/api/v1/projects/$PROJECT_ID/tickets" "{
\"title\": \"Created with helper\",
\"reporter_id\": \"$USER_ID\"
}" | jq .
# Update a ticket's status
alloy_patch "/api/v1/tickets/$TICKET_ID" '{"status": "Done"}' | jq .
{
"id": "dd0e8400-...",
"title": "Created with helper",
"status": "Backlog"
}
5. Error Handling
Always check HTTP status codes and parse error responses in automated scripts.
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="http://localhost:3000"
# TOKEN set from prior steps
# Use -w to capture the HTTP status code alongside the body
HTTP_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$BASE_URL/api/v1/projects" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"org_id": "invalid", "key": "BAD", "name": "Test"}')
# Split response body and status code
HTTP_BODY=$(echo "$HTTP_RESPONSE" | sed '$d')
HTTP_STATUS=$(echo "$HTTP_RESPONSE" | tail -1)
if [ "$HTTP_STATUS" -ge 200 ] && [ "$HTTP_STATUS" -lt 300 ]; then
echo "Success:"
echo "$HTTP_BODY" | jq .
else
echo "Error (HTTP $HTTP_STATUS):"
echo "$HTTP_BODY" | jq '.error'
exit 1
fi
Example error output:
Error (HTTP 404):
{
"code": "not_found",
"message": "Organization not found",
"details": []
}
Reusable error-checking function:
alloy_request() {
local method="$1"
local path="$2"
local data="${3:-}"
local args=(-s -w "\n%{http_code}" -X "$method"
"$BASE_URL$path"
-H "Authorization: Bearer $TOKEN")
if [ -n "$data" ]; then
args+=(-H "Content-Type: application/json" -d "$data")
fi
local response
response=$(curl "${args[@]}")
local body
body=$(echo "$response" | sed '$d')
local status
status=$(echo "$response" | tail -1)
if [ "$status" -ge 200 ] && [ "$status" -lt 300 ]; then
echo "$body"
else
echo "ERROR: HTTP $status — $(echo "$body" | jq -r '.error.message // "Unknown error"')" >&2
return 1
fi
}
# Usage:
alloy_request GET "/api/v1/projects?org_id=$ORG_ID" | jq '.items | length'
alloy_request POST "/api/v1/projects/$PROJECT_ID/tickets" \
"{\"title\": \"Safe ticket\", \"reporter_id\": \"$USER_ID\"}" | jq .
{
"id": "ee0e8400-...",
"title": "Safe ticket",
"status": "Backlog"
}
6. CI/CD Integration
API key authentication
Use API keys instead of password-based login for non-interactive environments.
API keys are passed as Bearer tokens in the Authorization header:
#!/usr/bin/env bash
set -euo pipefail
# In CI, set these as secret environment variables
BASE_URL="${ALLOY_BASE_URL:?ALLOY_BASE_URL must be set}"
TOKEN="${ALLOY_API_KEY:?ALLOY_API_KEY must be set}"
PROJECT_ID="${ALLOY_PROJECT_ID:?ALLOY_PROJECT_ID must be set}"
# Create a ticket from CI
TICKET=$(curl -s -w "\n%{http_code}" -X POST "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"title\": \"CI build #${CI_BUILD_NUMBER:-local}\",
\"description\": \"Automated ticket from CI pipeline\",
\"priority\": \"Low\",
\"reporter_id\": \"$USER_ID\"
}")
BODY=$(echo "$TICKET" | sed '$d')
STATUS=$(echo "$TICKET" | tail -1)
if [ "$STATUS" -ge 200 ] && [ "$STATUS" -lt 300 ]; then
echo "Ticket created: $(echo "$BODY" | jq -r '.id')"
exit 0
else
echo "Failed to create ticket (HTTP $STATUS):" >&2
echo "$BODY" | jq '.error' >&2
exit 1
fi
Creating an API key for CI
curl -s "$BASE_URL/api/v1/api-keys" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "CI Key", "scopes": ["read", "write"]}' | jq .
{
"id": "770e8400-...",
"name": "CI Key",
"key": "alloy_live_abc123...",
"key_prefix": "alloy_live_...",
"scopes": ["read", "write"],
"project_ids": [],
"created_at": "2026-03-28-...",
"expires_at": null
}
Store the key value as a secret in your CI system and use it as the Bearer
token. The key works identically to a JWT access token.
Exit codes for automation
Scripts should use meaningful exit codes so CI pipelines can react:
#!/usr/bin/env bash
set -euo pipefail
# Verify the API is healthy before running tests
HEALTH=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL/health")
if [ "$HEALTH" != "200" ]; then
echo "Alloy API is not healthy (HTTP $HEALTH)" >&2
exit 2
fi
# Run your operations...
echo "API is healthy, proceeding with automation."
exit 0
7. Examples
Create 10 tickets from a text file
Given backlog.txt:
Implement user avatars
Add dark mode support
Fix pagination on mobile
Optimize database queries
Add export to PDF
Implement webhook retries
Add team permissions
Create onboarding wizard
Fix timezone handling
Add bulk ticket import
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="http://localhost:3000"
# TOKEN, PROJECT_ID, USER_ID set from prior steps
COUNT=0
while IFS= read -r title; do
[ -z "$title" ] && continue
RESULT=$(curl -s -w "\n%{http_code}" -X POST \
"$BASE_URL/api/v1/projects/$PROJECT_ID/tickets" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"title\": \"$title\",
\"priority\": \"Medium\",
\"reporter_id\": \"$USER_ID\"
}")
BODY=$(echo "$RESULT" | sed '$d')
STATUS=$(echo "$RESULT" | tail -1)
if [ "$STATUS" -ge 200 ] && [ "$STATUS" -lt 300 ]; then
COUNT=$((COUNT + 1))
echo "[$COUNT] Created: $(echo "$BODY" | jq -r '.title') ($(echo "$BODY" | jq -r '.id'))"
else
echo "FAILED: $title (HTTP $STATUS)" >&2
fi
done < backlog.txt
echo "Created $COUNT tickets."
{
"id": "ff0e8400-...",
"title": "Implement user avatars",
"status": "Backlog",
"priority": "Medium",
"ticket_number": 1
}
Move all InProgress tickets to Done
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="http://localhost:3000"
# TOKEN, PROJECT_ID set from prior steps
# Fetch all InProgress tickets (paginate if needed)
CURSOR=""
MOVED=0
while true; do
QUERY="status=InProgress&limit=100"
[ -n "$CURSOR" ] && QUERY="$QUERY&cursor=$CURSOR"
RESPONSE=$(curl -s "$BASE_URL/api/v1/projects/$PROJECT_ID/tickets?$QUERY" \
-H "Authorization: Bearer $TOKEN")
TICKET_IDS=$(echo "$RESPONSE" | jq -r '.items[].id')
for TICKET_ID in $TICKET_IDS; do
curl -s -X POST "$BASE_URL/api/v1/tickets/$TICKET_ID/transition" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"to_status": "Done"}' | jq '{id: .id, title: .title, status: .status}'
MOVED=$((MOVED + 1))
done
HAS_MORE=$(echo "$RESPONSE" | jq -r '.has_more')
if [ "$HAS_MORE" != "true" ]; then
break
fi
CURSOR=$(echo "$RESPONSE" | jq -r '.next_cursor')
done
echo "Moved $MOVED tickets to Done."
{
"id": "aa0e8400-...",
"title": "Fix login bug",
"status": "Done"
}
Export time entries as CSV with jq
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="http://localhost:3000"
# TOKEN, PROJECT_ID set from prior steps
# Print CSV header
echo "id,ticket_id,date,duration_minutes,activity_type,description"
CURSOR=""
while true; do
QUERY="limit=100"
[ -n "$CURSOR" ] && QUERY="$QUERY&cursor=$CURSOR"
RESPONSE=$(curl -s "$BASE_URL/api/v1/projects/$PROJECT_ID/time-entries?$QUERY" \
-H "Authorization: Bearer $TOKEN")
# Convert each entry to a CSV row
echo "$RESPONSE" | jq -r '.items[] | [.id, .ticket_id, .date, .duration_minutes, .activity_type, (.description // "")] | @csv'
HAS_MORE=$(echo "$RESPONSE" | jq -r '.has_more')
if [ "$HAS_MORE" != "true" ]; then
break
fi
CURSOR=$(echo "$RESPONSE" | jq -r '.next_cursor')
done
Output:
id,ticket_id,date,duration_minutes,activity_type,description
"ff0e8400-...","aa0e8400-...","2026-03-28",120,"Coding","Investigated SSO bug"
"ff1e8400-...","bb0e8400-...","2026-03-28",60,"CodeReview","Reviewed auth PR"
Tip: Redirect the output to a file with
> time-entries.csvto save it, or pipe it to other tools for further processing.