Документация Рецепты API тест-планов

Рецепты API тест-планов

Готовые к вставке рецепты для каждого endpoint-а Test Plans. Каждый раздел показывает вызов cURL и эквивалент в SDK Go, Python и Java.

Об URL в примерах: все примеры используют http://localhost:5770. Замените на адрес вашего Mockarty (например https://mockarty.company.com), если инстанс живёт в другом месте. Подробности — в Полезных функциях и советах.

Связанные страницы: Тест-планы · Тест-планы в CI/CD · Справочник API

Аутентификация

Все endpoint-ы требуют API-токен в заголовке X-API-Key. Токены выпускаются в Settings → API Tokens (или через POST /api/v1/auth/tokens) с ролью, ограниченной namespace-ом.

export MOCKARTY_URL=http://localhost:5770
export MOCKARTY_API_TOKEN=mk_7_...
export NS=default

SDK-клиенты

Go

import (
    "context"
    "github.com/mockarty/mockarty-go/mockarty"
)

client, err := mockarty.NewClient(mockarty.Config{
    BaseURL: "http://localhost:5770",
    Token:   os.Getenv("MOCKARTY_API_TOKEN"),
})
ctx := context.Background()

Python

from mockarty import MockartyClient, TestPlan, TestPlanItem

client = MockartyClient(
    base_url="http://localhost:5770",
    token=os.environ["MOCKARTY_API_TOKEN"],
)

Java

import com.mockarty.MockartyClient;
import com.mockarty.model.*;

MockartyClient client = MockartyClient.builder()
    .baseUrl("http://localhost:5770")
    .token(System.getenv("MOCKARTY_API_TOKEN"))
    .build();

Карта endpoint-ов

Verb Путь Что делает
POST /api/v1/test-plans Создать план.
GET /api/v1/test-plans Список планов (с фильтром по namespace).
GET /api/v1/test-plans/:id Получить план по UUID или числовому ID.
PUT /api/v1/test-plans/:id Полная замена.
PATCH /api/v1/namespaces/:ns/test-plans/:idOrNumericID Частичное обновление с If-Match.
DELETE /api/v1/test-plans/:id Мягкое удаление (в Корзину).
POST /api/v1/test-plans/:id/run Запустить прогон.
GET /api/v1/test-runs/:runID Статус прогона.
POST /api/v1/test-runs/:runID/cancel Отменить идущий прогон.
GET /api/v1/test-plans/:planID/runs Список прогонов плана.
GET /api/v1/namespaces/:ns/test-plans/:planRef/runs/:runID/stream SSE-поток прогресса.
GET /api/v1/namespaces/:ns/test-plans/:idOrNumericID/runs/:runID/report Allure JSON-сводка.
GET /api/v1/namespaces/:ns/test-plans/:idOrNumericID/runs/:runID/report.zip Allure ZIP-архив.
GET /api/v1/namespaces/:ns/test-plans/:idOrNumericID/runs/:runID/report.junit.xml JUnit XML (для CI).
GET /api/v1/namespaces/:ns/test-plans/:idOrNumericID/runs/:runID/report.md Markdown-сводка (PR-комментарии).
GET /api/v1/namespaces/:ns/test-plans/:idOrNumericID/runs/:runID/report.html Самодостаточный HTML (открыть в браузере, Save-as-PDF).
GET /api/v1/namespaces/:ns/test-plans/:idOrNumericID/runs/:runID/report.unified.json Унифицированный JSON-отчёт.
POST /api/v1/namespaces/:ns/test-runs/ad-hoc Ad-hoc прогон (скрытый план).
GET / POST / PATCH / DELETE /api/v1/test-plans/:id/schedules(/:schedId) CRUD расписаний.
GET / POST / PATCH / DELETE /api/v1/test-plans/:id/webhooks(/:whId) CRUD вебхуков.
POST /api/v1/test-plans/:id/webhooks/:whId/test Синтетическая проверка вебхука.

Создание плана

cURL

curl -X POST "$MOCKARTY_URL/api/v1/test-plans" \
  -H "X-API-Key: $MOCKARTY_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "namespace": "default",
    "name": "Nightly regression",
    "description": "Functional + fuzz + chaos against staging",
    "items": [
      {"order": 1, "type": "functional", "refId": "11111111-1111-1111-1111-111111111111"},
      {"order": 2, "type": "fuzz",       "refId": "22222222-2222-2222-2222-222222222222"},
      {"order": 3, "type": "chaos",      "refId": "33333333-3333-3333-3333-333333333333"}
    ]
  }'

Ответ:

{
  "id": "5c0f13e4-...",
  "numericId": 42,
  "namespace": "default",
  "name": "Nightly regression",
  "items": [],
  "updatedAt": "2026-04-19T10:00:00Z"
}

Go

plan, err := client.TestPlans().Create(ctx, mockarty.TestPlan{
    Namespace:   "default",
    Name:        "Nightly regression",
    Description: "Functional + fuzz + chaos against staging",
    Items: []mockarty.TestPlanItem{
        {Order: 1, Type: mockarty.PlanItemTypeFunctional, ResourceID: "11111111-..."},
        {Order: 2, Type: mockarty.PlanItemTypeFuzz,       ResourceID: "22222222-..."},
        {Order: 3, Type: mockarty.PlanItemTypeChaos,      ResourceID: "33333333-..."},
    },
})

Python

plan = client.test_plans.create(TestPlan(
    namespace="default",
    name="Nightly regression",
    description="Functional + fuzz + chaos against staging",
    items=[
        TestPlanItem(order=1, type="functional", ref_id="11111111-..."),
        TestPlanItem(order=2, type="fuzz",       ref_id="22222222-..."),
        TestPlanItem(order=3, type="chaos",      ref_id="33333333-..."),
    ],
))

Java

TestPlan plan = client.testPlans().create(new TestPlan()
    .setNamespace("default")
    .setName("Nightly regression")
    .setItems(List.of(
        new TestPlanItem().setOrder(1).setType("functional").setResourceId("11111111-..."),
        new TestPlanItem().setOrder(2).setType("fuzz").setResourceId("22222222-..."),
        new TestPlanItem().setOrder(3).setType("chaos").setResourceId("33333333-...")
    )));

Список, получение и удаление

cURL

# Список
curl -H "X-API-Key: $MOCKARTY_API_TOKEN" \
     "$MOCKARTY_URL/api/v1/test-plans?namespace=$NS&limit=50"

# Получить по числовому ID (или UUID)
curl -H "X-API-Key: $MOCKARTY_API_TOKEN" \
     "$MOCKARTY_URL/api/v1/test-plans/42"

# Мягкое удаление (в Корзину)
curl -X DELETE \
     -H "X-API-Key: $MOCKARTY_API_TOKEN" \
     "$MOCKARTY_URL/api/v1/test-plans/42"

Go

plans, err := client.TestPlans().List(ctx, mockarty.ListPlansOptions{
    Namespace: "default", Limit: 50,
})
plan, err := client.TestPlans().Get(ctx, "42")
err = client.TestPlans().Delete(ctx, plan.ID)

Python

plans = client.test_plans.list(namespace="default", limit=50)
plan  = client.test_plans.get("42")
client.test_plans.delete(plan.id)

Java

List<TestPlan> plans = client.testPlans().list(
    ListPlansOptions.builder().namespace("default").limit(50).build());
TestPlan p = client.testPlans().get("42");
client.testPlans().delete(p.getId());

Частичное обновление (PATCH с If-Match)

PATCH /api/v1/namespaces/:ns/test-plans/:idOrNumericID требует заголовок If-Match с текущим сильным валидатором плана (updatedAt в миллисекундах Unix, в кавычках).

cURL

ETAG=$(curl -s -H "X-API-Key: $MOCKARTY_API_TOKEN" \
         "$MOCKARTY_URL/api/v1/test-plans/42" \
       | python3 -c 'import sys,json,datetime;d=json.load(sys.stdin); t=datetime.datetime.fromisoformat(d["updatedAt"].replace("Z","+00:00")); print(int(t.timestamp()*1000))')

curl -X PATCH "$MOCKARTY_URL/api/v1/namespaces/$NS/test-plans/42" \
  -H "X-API-Key: $MOCKARTY_API_TOKEN" \
  -H "Content-Type: application/json" \
  -H "If-Match: \"$ETAG\"" \
  -d '{"description": "Updated nightly suite"}'

Устаревший If-Match даёт 412 Precondition Failed.

Go

updated, err := client.TestPlans().Patch(ctx, "42",
    mockarty.PatchPlanRequest{Description: mockarty.Ptr("Updated nightly suite")},
    mockarty.PatchOptions{Namespace: "default"}, // IfMatch вычисляется автоматически
)

Python

updated = client.test_plans.patch(
    "42",
    {"description": "Updated nightly suite"},
    namespace="default",  # if_match берётся из текущего плана
)

Java

TestPlan updated = client.testPlans().patch("42",
    new PatchPlanRequest().setDescription("Updated nightly suite"),
    PatchOptions.builder().namespace("default").build());

Запуск прогона и ожидание

cURL

# Триггер
RUN=$(curl -s -X POST "$MOCKARTY_URL/api/v1/test-plans/42/run" \
       -H "X-API-Key: $MOCKARTY_API_TOKEN" \
       -H "Content-Type: application/json" \
       -d '{"mode": "parallel"}')
RUN_ID=$(echo "$RUN" | jq -r .runId)

# Опрос до терминала
while :; do
  STATUS=$(curl -s -H "X-API-Key: $MOCKARTY_API_TOKEN" \
            "$MOCKARTY_URL/api/v1/test-runs/$RUN_ID/status" \
          | jq -r .status)
  echo "status=$STATUS"
  case "$STATUS" in
    completed|failed|cancelled) break ;;
  esac
  sleep 5
done

Go

run, _ := client.TestPlans().Run(ctx, "42", mockarty.RunOptions{Mode: "parallel"})
final, err := client.TestPlans().WaitForRun(ctx, run.ID, 3*time.Second)
// err == nil                                → completed
// errors.Is(err, mockarty.ErrRunFailed)     → failed
// errors.Is(err, mockarty.ErrRunCancelled)  → cancelled

Python

run   = client.test_plans.run("42", mode="parallel")
final = client.test_plans.wait_for_run(run.id, poll_interval=3)

Java

TestPlanRun run = client.testPlans().run("42",
    RunOptions.builder().mode("parallel").build());
TestPlanRun final_ = client.testPlans().waitForRun(run.getId(), Duration.ofSeconds(3));

Запуск подмножества элементов

curl -X POST "$MOCKARTY_URL/api/v1/test-plans/42/run" \
  -H "X-API-Key: $MOCKARTY_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"items": [1, 3], "mode": "sequential"}'

Отмена идущего прогона

curl -X POST "$MOCKARTY_URL/api/v1/test-runs/$RUN_ID/cancel" \
  -H "X-API-Key: $MOCKARTY_API_TOKEN"

Стрим прогресса (SSE)

cURL

curl -N -H "X-API-Key: $MOCKARTY_API_TOKEN" \
  "$MOCKARTY_URL/api/v1/namespaces/$NS/test-plans/42/runs/$RUN_ID/stream"

Последовательность событий: run.started → N × (item.started / item.finished) → run.completed, плюс периодический heartbeat.

Go

events, _ := client.TestPlans().StreamRun(ctx, runID)
for ev := range events {
    log.Printf("%s %s %s", ev.Type, ev.ItemID, ev.Status)
}

Python

for ev in client.test_plans.stream_run(run.id):
    print(ev.type, ev.item_id, ev.status)

Java

client.testPlans().streamRun(run.getId(), ev ->
    System.out.println(ev.getType() + " " + ev.getItemId() + " " + ev.getStatus()));

Скачивание отчётов прогона

Mockarty отдаёт пять форматов отчёта с одного набора endpoint-ов. Allure JSON и ZIP кэшируются как артефакты и поддерживают If-None-Match; JUnit XML, Markdown и Unified JSON строятся на лету из результатов прогона.

Формат Endpoint CLI Назначение
Allure JSON GET .../runs/:runID/report mockarty-cli testplan report <runID> --format json Программный обход, богатая модель Allure.
Allure ZIP GET .../runs/:runID/report.zip mockarty-cli testplan report <runID> --format zip Загрузка в Allure Server / Allure TestOps.
JUnit XML GET .../runs/:runID/report.junit.xml mockarty-cli testplan report <runID> --format junit GitHub Actions / GitLab CI / Jenkins JUnit-виджеты.
Markdown GET .../runs/:runID/report.md mockarty-cli testplan report <runID> --format markdown Комментарии к PR, Slack, email.
HTML GET .../runs/:runID/report.html mockarty-cli testplan report <runID> --format html Самодостаточный документ для печати в PDF и архива.
Unified JSON GET .../runs/:runID/report.unified.json mockarty-cli testplan report <runID> --format unified Стабильная схема Mockarty для дашбордов и автоматизации.

cURL

# Allure JSON-сводка
curl -s -H "X-API-Key: $MOCKARTY_API_TOKEN" \
  "$MOCKARTY_URL/api/v1/namespaces/$NS/test-plans/42/runs/$RUN_ID/report" \
  -o report.json

# Allure ZIP
curl -s -H "X-API-Key: $MOCKARTY_API_TOKEN" \
  "$MOCKARTY_URL/api/v1/namespaces/$NS/test-plans/42/runs/$RUN_ID/report.zip" \
  -o allure.zip

# JUnit XML
curl -s -H "X-API-Key: $MOCKARTY_API_TOKEN" \
  "$MOCKARTY_URL/api/v1/namespaces/$NS/test-plans/42/runs/$RUN_ID/report.junit.xml" \
  -o report.junit.xml

# Markdown
curl -s -H "X-API-Key: $MOCKARTY_API_TOKEN" \
  "$MOCKARTY_URL/api/v1/namespaces/$NS/test-plans/42/runs/$RUN_ID/report.md" \
  -o report.md

# Самодостаточный HTML (открыть в браузере, Save-as-PDF)
curl -s -H "X-API-Key: $MOCKARTY_API_TOKEN" \
  "$MOCKARTY_URL/api/v1/namespaces/$NS/test-plans/42/runs/$RUN_ID/report.html" \
  -o report.html

# Unified JSON
curl -s -H "X-API-Key: $MOCKARTY_API_TOKEN" \
  "$MOCKARTY_URL/api/v1/namespaces/$NS/test-plans/42/runs/$RUN_ID/report.unified.json" \
  -o report.unified.json

Allure-endpoint-ы поддерживают If-None-Match, так что CI-пайплайны, опрашивающие готовность, платят только за HTTP round-trip.

Go

allure, _  := client.TestPlans().GetRunReport(ctx, "default", "42", runID)
zipRC,  _  := client.TestPlans().GetRunReportZIP(ctx, "default", "42", runID)
defer zipRC.Close()
io.Copy(outFile, zipRC)

junitXML, _ := client.TestPlans().GetRunReportJUnit(ctx, "default", "42", runID)
markdown, _ := client.TestPlans().GetRunReportMarkdown(ctx, "default", "42", runID)
html,     _ := client.TestPlans().GetRunReportHTML(ctx, "default", "42", runID)
unified,  _ := client.TestPlans().GetRunReportUnified(ctx, "default", "42", runID)
// unified.Raw содержит исходные байты для дальнейшей передачи.

Python

summary = client.test_plans.get_run_report("42", run.id, namespace="default")
with open("allure.zip", "wb") as f:
    client.test_plans.get_run_report_zip("42", run.id, f, namespace="default")

junit_xml = client.test_plans.get_run_report_junit("42", run.id, namespace="default")
markdown  = client.test_plans.get_run_report_markdown("42", run.id, namespace="default")
html      = client.test_plans.get_run_report_html("42", run.id, namespace="default")
unified   = client.test_plans.get_run_report_unified("42", run.id, namespace="default")
# unified.raw содержит исходные байты, unified.counts.failed и т. д. — типизированные поля.

Java

AllureReport summary = client.testPlans().getRunReport("default", "42", runID);
try (InputStream zip = client.testPlans().getRunReportZip("default", "42", runID);
     OutputStream out = Files.newOutputStream(Path.of("allure.zip"))) {
    zip.transferTo(out);
}

byte[]        junitXml = client.testPlans().getRunReportJUnit("default", "42", runID);
byte[]        markdown = client.testPlans().getRunReportMarkdown("default", "42", runID);
byte[]        html     = client.testPlans().getRunReportHTML("default", "42", runID);
UnifiedReport unified  = client.testPlans().getRunReportUnified("default", "42", runID);
// unified.getRaw() — исходные байты, unified.getCounts().getFailed() — типизированные поля.

Ad-hoc прогон

POST /api/v1/namespaces/:ns/test-runs/ad-hoc создаёт скрытый план и диспетчеризует прогон одним вызовом — идеально для динамически собираемых CI-пайплайнов.

cURL

curl -X POST "$MOCKARTY_URL/api/v1/namespaces/$NS/test-runs/ad-hoc" \
  -H "X-API-Key: $MOCKARTY_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "pr-1234 smoke",
    "items": [
      {"order": 1, "type": "functional", "ref_id": "11111111-..."},
      {"order": 2, "type": "contract",   "ref_id": "44444444-..."}
    ]
  }'

Ответ:

{
  "run_id": "8a1f62d0-...",
  "plan_id": "9c0e...",
  "status": "running",
  "_links": {
    "self":   "/api/v1/test-runs/8a1f62d0-...",
    "status": "/api/v1/test-runs/8a1f62d0-.../status",
    "report": "/api/v1/namespaces/default/test-plans/9c0e.../runs/8a1f62d0-.../report"
  }
}

Endpoint ad-hoc отвечает 503 на узлах, где оркестратор не подключён (например, в упрощённых desktop-сборках).

Необязательное "tags": ["release-42", "smoke"] помечает прогон при создании. Ответ содержит нормализованные метки; endpoint чтения прогона возвращает те же значения. Некорректные метки дают 400 до создания плана. Ограничения описаны в разделе Разовые прогоны.

Добавьте "idempotency_key":"job-4211", если CI может повторить POST. Прежнее тело и ключ вернут исходный запуск с "replayed":true; изменение тела с тем же ключом даст 409. Новому заданию нужен новый ключ. Сервер хранит только хеш ключа.

Для существующего прогона добавьте метки атомарно через POST /api/v1/namespaces/<namespace>/test-plan-runs/<runId>/tags/merge с телом {"tags":["smoke"]}. Ответ содержит полный список tags и признак changed. Повтор запроса безопасен; метка релиза и метки других процессов сохраняются. Существующий endpoint /tags сохраняет режим полной замены.

Go

resp, err := client.TestPlans().CreateAdHocRun(ctx, mockarty.CreateAdHocRunRequest{
    Namespace: "default",
    Name:      "pr-1234 smoke",
    Items: []mockarty.TestPlanItem{
        {Order: 1, Type: "functional", ResourceID: "11111111-..."},
        {Order: 2, Type: "contract",   ResourceID: "44444444-..."},
    },
})

Python

resp = client.test_plans.create_ad_hoc_run(
    namespace="default",
    name="pr-1234 smoke",
    items=[
        TestPlanItem(order=1, type="functional", ref_id="11111111-..."),
        TestPlanItem(order=2, type="contract",   ref_id="44444444-..."),
    ],
)

Java

AdHocRunResponse resp = client.testPlans().createAdHocRun(
    CreateAdHocRunRequest.builder()
        .namespace("default")
        .name("pr-1234 smoke")
        .items(List.of(
            new TestPlanItem().setOrder(1).setType("functional").setResourceId("11111111-..."),
            new TestPlanItem().setOrder(2).setType("contract").setResourceId("44444444-...")
        ))
        .build());

Расписания

Список расписаний

curl -H "X-API-Key: $MOCKARTY_API_TOKEN" \
     "$MOCKARTY_URL/api/v1/test-plans/42/schedules"

Создать cron-расписание

cURL

curl -X POST "$MOCKARTY_URL/api/v1/test-plans/42/schedules" \
  -H "X-API-Key: $MOCKARTY_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name":     "nightly",
    "kind":     "cron",
    "timezone": "Europe/Moscow",
    "payload":  {"expr": "0 2 * * *"}
  }'

Go

sch, _ := client.TestPlans().AddSchedule(ctx, planID, mockarty.Schedule{
    Name: "nightly", Kind: "cron", Timezone: "Europe/Moscow",
    Payload: mockarty.SchedulePayload{"expr": "0 2 * * *"},
})

Python

sch = client.test_plans.add_schedule(plan.id, Schedule(
    name="nightly", kind="cron", timezone="Europe/Moscow",
    payload={"expr": "0 2 * * *"},
))

Java

Schedule sch = client.testPlans().addSchedule(plan.getId(), new Schedule()
    .setName("nightly").setKind("cron").setTimezone("Europe/Moscow")
    .setPayload(Map.of("expr", "0 2 * * *")));

Одноразовое и интервальное расписание

# Once (сработает 2026-05-01 00:00 UTC и сам отключится)
curl -X POST "$MOCKARTY_URL/api/v1/test-plans/42/schedules" \
  -H "X-API-Key: $MOCKARTY_API_TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"launch","kind":"once","payload":{"fire_at":"2026-05-01T00:00:00Z"}}'

# Interval (каждые 15 минут)
curl -X POST "$MOCKARTY_URL/api/v1/test-plans/42/schedules" \
  -H "X-API-Key: $MOCKARTY_API_TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"smoke","kind":"interval","payload":{"every_seconds":900}}'

Обновление / удаление

# Временно отключить
curl -X PATCH "$MOCKARTY_URL/api/v1/test-plans/42/schedules/$SCHED_ID" \
  -H "X-API-Key: $MOCKARTY_API_TOKEN" -H "Content-Type: application/json" \
  -d '{"enabled": false}'

curl -X DELETE "$MOCKARTY_URL/api/v1/test-plans/42/schedules/$SCHED_ID" \
  -H "X-API-Key: $MOCKARTY_API_TOKEN"

Вебхуки

Создать вебхук

cURL

curl -X POST "$MOCKARTY_URL/api/v1/test-plans/42/webhooks" \
  -H "X-API-Key: $MOCKARTY_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name":           "ci-slack",
    "url":            "https://hooks.example.com/mockarty",
    "secret":         "keep-me-safe",
    "events":         ["run_finished", "item_failed"],
    "retryCount":     3,
    "backoffSeconds": 5
  }'

Секрет — write-only: сервер возвращает пустую строку при чтении. Ротация — PATCH { "secret": "<new>" }.

Валидация исходящего URL (защита от SSRF): требуется HTTPS, встроенные учётные данные запрещены, запрещены также любые литералы и DNS-адреса из loopback / RFC 1918 / link-local / multicast, а также хосты, совпадающие с localhost, *.internal, *.cluster.local.

Go

wh, _ := client.TestPlans().AddWebhook(ctx, planID, mockarty.Webhook{
    Name:           "ci-slack",
    URL:            "https://hooks.example.com/mockarty",
    Secret:         "keep-me-safe",
    Events:         []string{"run_finished", "item_failed"},
    RetryCount:     3,
    BackoffSeconds: 5,
    Enabled:        true,
})

Python

wh = client.test_plans.add_webhook(plan.id, Webhook(
    name="ci-slack",
    url="https://hooks.example.com/mockarty",
    secret="keep-me-safe",
    events=["run_finished", "item_failed"],
    retry_count=3, backoff_seconds=5,
))

Java

Webhook wh = client.testPlans().addWebhook(plan.getId(), new Webhook()
    .setName("ci-slack")
    .setUrl("https://hooks.example.com/mockarty")
    .setSecret("keep-me-safe")
    .setEvents(List.of("run_finished", "item_failed"))
    .setRetryCount(3).setBackoffSeconds(5));

Проверка вебхука (dry-run)

curl -X POST "$MOCKARTY_URL/api/v1/test-plans/42/webhooks/$WH_ID/test" \
  -H "X-API-Key: $MOCKARTY_API_TOKEN"

Помещает в очередь синтетический payload run_started, чтобы получатель мог end-to-end проверить подпись и HTTPS-коннект.

Проверка подписи на получателе (Go)

sig := r.Header.Get("X-Mockarty-Signature") // "sha256=<hex>" или чистый hex
ts  := r.Header.Get("X-Mockarty-Timestamp") // RFC3339Nano

body, _ := io.ReadAll(r.Body)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))

if !hmac.Equal([]byte(expected), []byte(sig)) {
    http.Error(w, "bad signature", http.StatusUnauthorized)
    return
}

t, err := time.Parse(time.RFC3339Nano, ts)
if err != nil || time.Since(t) > 5*time.Minute {
    http.Error(w, "stale request", http.StatusUnauthorized)
    return
}

Получатель должен отбрасывать всё, что выходит за окно ±5 минут от текущих часов — это защита от replay.

Список, обновление и удаление вебхуков

# Список
curl -H "X-API-Key: $MOCKARTY_API_TOKEN" \
     "$MOCKARTY_URL/api/v1/test-plans/42/webhooks"

# Ротация секрета + отключение
curl -X PATCH "$MOCKARTY_URL/api/v1/test-plans/42/webhooks/$WH_ID" \
  -H "X-API-Key: $MOCKARTY_API_TOKEN" -H "Content-Type: application/json" \
  -d '{"secret":"rotated-secret","enabled":false}'

# Удаление
curl -X DELETE "$MOCKARTY_URL/api/v1/test-plans/42/webhooks/$WH_ID" \
  -H "X-API-Key: $MOCKARTY_API_TOKEN"

Справочник ошибок

HTTP Значение
400 Плохой JSON, неверный enum, неуникальный order, отсутствует обязательное поле. Тело содержит {"error":"..."}.
401 X-API-Key отсутствует или невалиден.
403 Токен валиден, но у роли нет прав на операцию.
404 Ресурс не найден или принадлежит чужому namespace (без утечки факта существования).
409 Конфликт имени / числового ID.
412 Валидатор If-Match не совпал на PATCH — перечитайте и повторите.
422 Ошибка валидации (например, every_seconds < 10, небезопасный URL вебхука).
503 Оркестратор не подключён на этом admin-узле (ad-hoc, SSE).

Куда дальше