Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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)
  • curl and jq installed
  • A terminal with shell variable support (bash or zsh)

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.csv to save it, or pipe it to other tools for further processing.