문서
전송 웹훅: 자체 사이트에서 글 받기
AutoPSEO 글을 사이트로 보내는 서명된 웹훅: 헤더, 이벤트별 페이로드, 응답 방식, 재시도, 대기 중인 명령, IndexNow를 설명합니다.
AutoPSEO는 게시, 수정, 삭제하는 모든 글을 서명된 HTTPS POST 요청으로 사용자의 사이트에 보냅니다. 사이트가 글을 저장하고 자체 템플릿으로 렌더링하므로, 콘텐츠는 사용자의 시스템에 남고 AutoPSEO를 떠나더라도 계속 동작합니다. 이 페이지는 수신기를 구현하는 개발자를 위한 참고 문서로, 서명, 이벤트별 페이로드, AutoPSEO가 읽는 응답, 재시도, 대기 중인 명령, IndexNow를 다룹니다.
웹훅 설정하기
- 속성의 Content 페이지에서 Where articles are published로 이동합니다.
- Webhook address on your site를 입력합니다. 공개 호스트 이름을 가진
https://URL이어야 하며, IP 주소와localhost는 허용되지 않습니다. AutoPSEO는 리디렉션을 따라가지 않으므로 최종 주소를 입력하세요. - Your site's address를 입력합니다(예:
https://example.com). 각 콘텐츠 유형의 경로는 이 주소 아래에 붙으므로,/answers유형의 글은https://example.com/answers/<slug>에 위치합니다. - Save and send a test를 선택합니다. AutoPSEO가 서명 시크릿(
whsec_뒤에 16진수 48자)을 만들고 사용자의 주소로 ping을 보냅니다. 서명을 검증하는 수신기는 아직 시크릿이 없으므로 이 첫 번째 ping에서 실패합니다. - Show 또는 Copy로 Signing secret을 확인하고 사이트에 시크릿으로 저장한 다음 Send a test를 선택합니다.
- What your site handles를 확인합니다. 각 명령은 Connected, Not connected, Declared, not tested, Not tested 중 하나로 표시됩니다.
새 속성은 AutoPSEO가 자체 주소에서 글을 호스팅하는 상태로 시작하며(호스팅 참고), 호스팅과 웹훅은 함께 사용할 수 있습니다. 사이트에 글이 표시되면 Stop hosting on AutoPSEO를 선택하세요. 게시 중인 모든 글이 사이트 주소로 옮겨지고 article.published로 웹훅에 전송됩니다. 이 버튼을 쓰려면 마지막 테스트에 응답한 웹훅과 사이트 주소가 있어야 합니다. 호스팅이 꺼져 있는 동안에는 웹훅을 삭제할 수 없습니다.
New secret은 서명 시크릿을 즉시 교체하며, 이전 시크릿은 곧바로 검증되지 않습니다. 새 시크릿을 만든 직후 사이트에 반영하세요.
AutoPSEO는 마지막 ping에 2xx로 응답한 웹훅에 대해서만 전송을 큐에 넣습니다. ping이 실패하면(저장 시, Send a test 시, 사이트 스캔 시작 시) 웹훅이 오류 상태가 되고, 오류 상태인 동안 게시된 글은 해당 웹훅의 큐에 들어가지 않습니다. ping에 계속 응답하도록 유지하고, 장애를 해결한 뒤에는 Send a test를 선택하세요.
헤더와 서명
모든 요청은 JSON 본문과 다음 헤더를 가진 POST입니다.
| 헤더 | 값 |
|---|---|
Content-Type | application/json |
User-Agent | AutoPSEO-Webhook/1 |
X-AutoPSEO-Event | 이벤트 이름. 본문에도 event로 들어 있습니다 |
X-AutoPSEO-Delivery | 전송 id. 본문에도 id로 들어 있으며, 한 전송을 재시도해도 동일합니다 |
X-AutoPSEO-Timestamp | 이번 시도를 서명한 시각의 Unix 시간(초) |
X-AutoPSEO-Signature | sha256= 뒤에 <timestamp>.<raw body>의 HMAC-SHA256 16진수 값 |
요청을 검증하는 방법은 다음과 같습니다.
- 타임스탬프 헤더, 마침표, 그리고 JSON 파싱 전의 원본 요청 본문을 받은 그대로 이어 붙입니다.
- 이 문자열에 HMAC-SHA256을 계산합니다. 키는 앱에 표시된 서명 시크릿 전체이며
whsec_접두사를 포함해 UTF-8 바이트로 사용합니다. - 결과를 16진수로 인코딩하여
X-AutoPSEO-Signature에서sha256=뒤의 값과 상수 시간 방식으로 비교합니다. 다르면 요청을 거부하세요.
각 시도는 고유한 타임스탬프로 새로 서명되므로, 타임스탬프가 사이트 시계와 크게 차이 나는 요청(보통 5분 허용)을 거부해도 재시도에는 영향이 없습니다. HMAC 자체는 RFC 2104에 정의되어 있습니다.
모든 본문에는 id, event, occurredAt(ISO 8601, UTC), site: { propertyId, domain }이 들어 있습니다.
이벤트
| 이벤트 | 종류 | AutoPSEO가 보내는 시점 |
|---|---|---|
ping | 확인 | 웹훅을 저장할 때, Send a test 시, 모든 사이트 스캔이 시작될 때 |
test | 확인 | ping이 성공한 직후, 명령마다 하나씩 |
article.published | 전송 | 글이 처음 게시될 때. 호스팅을 중단하면 게시 중인 모든 글 |
article.updated | 전송 | 새 리비전이 게시될 때(새로고침, 최적화, 롤백, 삭제 후 재게시, 언어 추가) 또는 커버가 추가될 때 |
article.unpublished | 전송 | 글이 삭제되거나 다른 페이지로 병합될 때 |
path.add | 전송 | 유형이 지정된 콘텐츠 유형의 새 값이 승인될 때 |
path.remove | 전송 | 누군가 값의 경로를 삭제하도록 요청했을 때 |
path.check | 호출 | AutoPSEO가 사이트에 페이지가 없는 주소에 페이지를 계획하거나 새 값을 제안하기 전 |
site.export | 호출 | 사이트 스캔 중, 매일 하는 주제 동기화 중, AutoPSEO가 게시 중인 글을 변경하기 전 |
articles.adopted | 호출 | 스캔이 사이트에서 내보낸 글을 가져온 뒤 |
site.handover | 호출 | 콘텐츠 유형의 작성을 AutoPSEO에 넘기거나 되돌릴 때 |
전송은 아웃박스를 거칩니다. 변경과 같은 데이터베이스 배치에 기록되고, 즉시 전송되며, 재시도되고, 사이트를 기다리며 대기할 수 있습니다. 호출은 단순한 요청과 응답이며 AutoPSEO가 그 자리에서 응답을 읽습니다. 경로 명령이 전송되는 시점은 유형이 지정된 페이지와 새 경로에서 설명합니다.
글 페이로드
article.published와 article.updated에는 모든 언어를 포함한 글 전체가 담깁니다.
{
"id": "0f6d2c1e-7a51-4c1b-9d0e-5b8f3a2e9c41",
"event": "article.published",
"occurredAt": "2026-10-12T08:30:00.000Z",
"site": { "propertyId": "prop_123", "domain": "example.com" },
"article": {
"id": "a4c09e2b-6f1d-4e57-8b3a-1d2e3f4a5b6c",
"slug": "plan-a-content-calendar",
"path": "/answers/plan-a-content-calendar",
"type": { "key": "answers", "name": "Answers", "path": "/answers" },
"lang": "en",
"langs": ["en", "de"],
"rev": 1,
"url": "https://example.com/answers/plan-a-content-calendar",
"datePublished": "2026-10-12",
"dateModified": "2026-10-12",
"contentHash": "9f2c4e1a7b3d5f6081a2c3d4",
"writer": "platform",
"category": "Planning",
"copy": {
"title": "How to plan a content calendar",
"metaDescription": "A content calendar in five steps, with a template.",
"h1": "How to plan a content calendar",
"directAnswer": "List your topics, give each a date and an owner, then review the plan weekly.",
"keyTakeaways": ["Plan a quarter at a time.", "Give every piece one owner."],
"sections": [{ "heading": "Start from your topics", "bodyHtml": "<p>Begin with the searches you want to win.</p>" }],
"faq": [{ "q": "How far ahead should I plan?", "a": "One quarter is enough for most teams." }],
"sources": [{ "title": "Creating helpful content", "url": "https://developers.google.com/search/docs/fundamentals/creating-helpful-content" }]
},
"translations": { "de": { "title": "Einen Redaktionsplan erstellen", "h1": "Einen Redaktionsplan erstellen" } },
"cover": {
"url": "https://app.autopseo.com/media/covers/prop_123/<sha256>.jpg",
"sha256": "<sha256>",
"contentType": "image/jpeg",
"width": 1536,
"height": 1024
}
}
}
독일어 항목은 여기서 줄여 놓았으며, 각 번역은 copy와 같은 구조를 가집니다.
| 필드 | 의미 |
|---|---|
id | AutoPSEO가 글에 부여한 id. 바뀌지 않으므로 안전한 키로 쓸 수 있습니다 |
slug | 주소의 마지막 부분. 유형이 지정된 페이지에서는 google/software-engineer처럼 /로 이어진 세그먼트입니다 |
path | 사이트 기준 상대 주소로, 유형의 경로에 slug를 붙인 값입니다. 페이지가 생성되면 고정됩니다 |
type | { key, name, path }. key는 변하지 않으며, path는 해당 유형의 글이 위치하는 곳입니다(사이트 루트이면 비어 있음) |
lang, langs | 기본 언어와 이 리비전의 모든 언어 |
rev | 리비전 번호 |
url | AutoPSEO가 알고 있는 전체 주소. 호스팅을 끄면 사이트의 주소이고, 그렇지 않으면 수신기가 마지막으로 응답한 url입니다 |
datePublished, dateModified | YYYY-MM-DD 형식. 게시일은 이후 리비전에서도 유지됩니다 |
contentHash | 리비전의 지문 |
writer | 리비전을 작성한 주체: platform(AutoPSEO), mcp(사용자의 AI), user, import(사이트 자체 텍스트), aggregate(목록 페이지) |
copy | 기본 언어의 글 |
translations | 언어 코드를 키로 하는 다른 언어 |
category | 사이트 형식에서 카테고리를 유지하는 경우, 해당 글의 사이트 카테고리 |
topic | 사이트가 넘긴 주제로 글을 작성한 경우 { externalId, keyword }. 이를 이용해 해당 주제를 닫을 수 있습니다 |
parts | 유형이 지정된 페이지에서 각 부분의 값: { "company": "google", "role": "software-engineer" } |
kind | 목록 페이지에서는 "list". copy는 { title, h1, items: [{ from, text }] }이며, from은 각 항목의 출처 페이지 경로이고 parts는 목록이 모으는 값을 나타냅니다 |
cover | AutoPSEO가 글을 위해 만든 커버. 한 번 가져와 자체 사본으로 보관하세요. 파일 이름이 이미지의 SHA-256이므로 주소는 바뀌지 않습니다 |
basedOn | article.updated에서: 이 리비전이 기준으로 삼은 사용자 페이지의 버전(아래 충돌 항목 참고) |
redirectTo | 병합 후의 article.unpublished에서: 이전 주소가 이제 향하는 곳. 301로 응답하고 그 주소에는 새로 아무것도 두지 마세요 |
article.unpublished에는 copy, translations, category, cover를 제외한 동일한 article 필드가 담깁니다.
copy 내부
| 필드 | 의미 |
|---|---|
title, metaDescription, h1 | 페이지 제목, 메타 설명, 헤딩 |
directAnswer | 가장 먼저 보여 주는 짧은 답변 |
keyTakeaways | 문자열 목록 |
sections | [{ heading, bodyHtml }] |
faq | [{ q, a }] |
sources | [{ title, url, lastVerified? }] |
question | 선택 사항: 사이트 형식에서 요구하는 경우, 검색자가 쓴 표현 그대로의 질문 |
category | 선택 사항: 사이트의 카테고리 중 하나 |
lists | 선택 사항: 키별 일반 텍스트 목록으로, 이를 요구하는 콘텐츠 유형에 사용됩니다(면접 가이드의 예시 질문 등) |
AutoPSEO가 작성한 글에서 bodyHtml은 p, ul, li, strong, em, a, code만 사용하며 링크에는 href만 남습니다. 사이트에서 가져온 텍스트는 사용자의 HTML을 그대로 유지합니다. 템플릿에서 요구한다면 직접 갖춘 sanitiser로도 처리하세요.
수신기가 응답하는 방법
2xx 응답이면 전송이 완료된 것으로 처리됩니다. AutoPSEO는 응답 본문의 선택적 JSON 객체도 읽습니다. 64 KB보다 큰 응답은 실패로 처리되므로 그 이하로 유지하세요.
| 필드 | 효과 |
|---|---|
url | 글이 현재 위치한 https:// 주소. AutoPSEO가 글의 URL로 기록합니다 |
version | 사용자 페이지의 버전으로, 최대 128자의 불투명 문자열입니다. 다음 업데이트에서 basedOn으로 돌아옵니다 |
urls | 이 전송으로 변경된 https:// 주소로, IndexNow에 사용됩니다(모든 언어 또는 더 이상 제공하지 않는 주소). []는 변경된 것이 없다는 뜻입니다 |
cover | 글에 쓸 커버가 없고 전송에도 커버가 없을 때 "wanted". AutoPSEO가 커버를 만들어 article.updated로 보냅니다 |
오류 응답은 각각 다른 의미를 가집니다.
| 사용자의 응답 | AutoPSEO의 해석 | 결과 |
|---|---|---|
{ "error": "unknown_event" }가 있는 4xx 또는 501 | 이 명령을 아직 처리하지 않음 | 전송은 재시도 없이 대기합니다 |
{ "error": "conflict" }가 있는 409 | 업데이트가 사용자 페이지에 더 이상 없는 버전을 기준으로 작성됨 | 전송은 재시도 없이 실패하고, 다음 변경 전에 사이트에서 글을 다시 읽습니다 |
path.add 또는 path.remove에 대한 { "error": "<code>", "details": [] }가 있는 408 또는 429를 제외한 4xx | 사용자가 이유를 밝힌 거부 | 기록됩니다. 유형이 지정된 페이지와 새 경로 참고 |
| 그 밖의 모든 경우: 10초 내 무응답, 네트워크 오류, 리디렉션, 408, 429, 5xx 또는 다른 4xx | 일시적 실패 | 백오프를 두고 재시도합니다 |
재시도와 대체된 전송
전송은 변경이 저장되는 즉시 보내지며, 5분 주기의 스케줄이 대기 중인 항목을 오래된 순으로 보냅니다. 일시적 실패 후 AutoPSEO는 시도 사이에 1분, 5분, 15분, 1시간, 3시간, 6시간, 12시간을 기다립니다. 여덟 번째 시도가 실패하면 전송은 실패로 표시되고, Content 페이지의 웹훅 옆에 빨간 배지로 집계됩니다.
같은 글에 대한 더 새로운 전송이 있으면, 아직 대기 중인 이전 전송은 대체됨으로 표시되어 전송되지 않습니다. 모든 전송에 글 전체가 담기므로 사이트에는 가장 최근 전송만 있으면 됩니다. 같은 값에 대한 경로 명령도 같은 방식으로 처리됩니다.
수신기는 같은 전송을 두 번 받을 수 있습니다. 예를 들어 글을 저장했지만 10초 타임아웃 이후에 응답한 경우입니다. X-AutoPSEO-Delivery로 중복을 제거하거나, article.id를 기준으로 저장해 모든 쓰기를 멱등하게 만드세요. 삭제된 글을 다시 게시하면 업데이트가 전송되므로, article.unpublished 이후 삭제한 글에 article.updated가 도착할 수도 있습니다. 업데이트는 "생성 또는 교체"로 처리하세요.
사이트가 아직 처리하지 않는 명령
사이트는 프로토콜을 명령 단위로 하나씩 구현할 수 있습니다. AutoPSEO는 명령마다 상태를 보관하며 세 가지 방법으로 파악합니다.
- 테스트. ping이 성공할 때마다 AutoPSEO는 명령마다
test를 하나씩 보냅니다. 본문은{ id, event: "test", occurredAt, site, test: { event: "<command>", ... } }이며,test에는 실제 명령과 같은 형태의 샘플과<type path>/autopseo-connection-test에 대한 내용이 들어 있습니다. 이 주소는 어떤 사이트에도 없습니다. 실제 명령처럼 검사하되 아무것도 변경하지 말고 2xx로 응답하세요. Connected이면{ "test": "<command>", "ok": true }, Not connected이면{ "test": "<command>", "ok": false, "error": "unknown_event" }입니다. 가지고 있지 않은 주소의 삭제처럼ok: false에 다른 오류 코드가 오는 응답도 연결된 것으로 집계됩니다. 샘플은 최상위가 아닌test아래에 있으므로, 테스트를 모르는 수신기는 그 안에서 글을 찾지 못합니다. - 실제 전송과 호출. 2xx이면 명령이 연결된 것으로 표시되고,
unknown_event이면 연결되지 않은 것으로 표시됩니다. - ping 응답. 응답의
autopseo.events에 나열된 명령은 테스트나 전송으로 더 확인되기 전까지 Declared, not tested로 표시됩니다.
연결되지 않은 명령에 대한 전송은 대기합니다. Content 페이지에는 대기 중인 항목이 "Your site does not take articles down yet"처럼 나열되며, 직접 처리한 항목에는 Done on the site 버튼이 있습니다. 이 버튼을 누르면 AutoPSEO는 해당 전송을 더 이상 보내지 않습니다. 이후 테스트나 전송에서 명령이 연결된 것으로 확인되면 그 명령을 기다리던 모든 항목이 전송됩니다.
ping 응답
ping에는 2xx로 응답하세요. 글을 받기만 하는 사이트는 그 이상 필요하지 않습니다. 사이트가 그 밖에 어떤 일을 하는지 AutoPSEO에 알리려면 autopseo 객체를 포함하세요.
{
"ok": true,
"autopseo": {
"version": 1,
"events": ["article.published", "article.updated", "article.unpublished"],
"covers": true,
"indexNow": { "key": "3f1c9a7e5b2d4f60", "keyLocation": "https://example.com/3f1c9a7e5b2d4f60.txt" },
"export": ["articles", "topics", "profile"],
"adopt": true,
"handover": true,
"types": ["answers"],
"writer": "site"
}
}
| 필드 | 의미 |
|---|---|
version | 1. 버전이 1 이상이 아니면 객체는 무시됩니다 |
events | 처리하는 명령으로, 테스트 전까지는 선언된 상태로 표시됩니다 |
covers | 페이지가 글마다 커버를 보여 준다면 true. 이 경우 AutoPSEO의 작성기가 글을 쓰면서 커버를 만들어 첫 전송에 포함하고, 사용자의 AI에게도 커버를 제공하도록 요청합니다. 커버 참고 |
indexNow | { key, keyLocation? }: 제공하는 IndexNow 키(영문자, 숫자, 대시 8~128자)와, 키 파일이 /<key>.txt에 없을 때 그 https:// 위치 |
export | site.export가 넘기는 항목: articles, topics, profile |
adopt | articles.adopted를 수락하여, 전체 스캔이 기존 글을 가져올 수 있음 |
handover | site.handover를 수락함 |
types | 수락하는 콘텐츠 유형 키(최대 50개) |
writer | 현재 새 글을 작성하는 주체: site 또는 autopseo |
제공하는 주소를 위한 IndexNow
ping 응답에 indexNow를 선언하면, AutoPSEO는 수락된 전송 이후에 api.indexnow.org의 IndexNow로 ping을 보내며, 사이트가 직접 ping을 보낼 필요는 없습니다.
- 응답에 나열된
urls를 보냅니다.urls가 없으면 응답의url, 그것도 없으면 글의 URL을 보냅니다.urls: []이면 아무것도 보내지 않습니다. - IndexNow 프로토콜이 요구하는 대로 사용자 호스트의 주소만 포함됩니다. 호스트는
keyLocation의 호스트이며, 없으면 사이트 주소의 호스트입니다. - 전송 실행 한 번에 사이트당 ping 한 번, 최대 10,000개의 주소를 보냅니다.
- 200과 202는 수락됩니다. 429, 5xx 또는 무응답이면 주소를 유지한 채 1시간 후에 다시 시도하며, 이후 시도마다 간격이 두 배로 늘어 최대 하루까지 늘어납니다(
Retry-After가 요청하면 더 길어짐). 그동안 이후 전송이 추가한 주소도 함께 보냅니다. 403(키가 유효하지 않음)이나 422(호스트에 속하지 않는 주소)는 기록되고 폐기됩니다. - Content 페이지에는 마지막 ping의 시각, 주소 수, HTTP 상태, 다음 시도 시각이 표시됩니다.
- AutoPSEO가 글을 함께 호스팅하는 동안에는 호스팅되는 페이지가 AutoPSEO 자체 키로 ping되며, 사용자의 키는 사용되지 않습니다.
Indexing 아래의 IndexNow 도구는 별도의 수동 제출 도구입니다. IndexNow를 참고하세요.
충돌: 사이트의 텍스트가 원본입니다
응답의 version(또는 내보내기에 포함된 글의 version)은 AutoPSEO가 사용자의 페이지가 있다고 믿는 버전입니다. 모든 article.updated에는 이 값이 basedOn으로 들어 있습니다. 예를 들어 편집자의 수정 등으로 그 이후 사이트에서 페이지가 바뀌었다면, 아무것도 저장하지 말고 { "error": "conflict" }와 함께 409로 응답하세요. 전송은 재시도 없이 실패합니다.
게시 중인 글에 대기 중인 변경이 기록되기 전에, 사이트가 site.export를 처리한다면 AutoPSEO는 site.export와 paths로 사이트에서 그 글을 다시 읽습니다. 사용자의 버전이 앞서 있다면 사용자의 텍스트가 글의 다음 리비전(작성자 import)이 되고, 이어지는 업데이트에는 사용자의 현재 버전이 basedOn으로 담깁니다.
path.add와 path.remove
이 두 전송은 유형이 지정된 콘텐츠 유형의 값 하나에 관한 것으로, 예를 들어 /interviews/{company}/{role}의 회사가 해당합니다. article 대신 path 객체를 담고 있습니다.
{
"id": "7c1e9d2a-3b4f-4e8a-9c0d-1e2f3a4b5c6d",
"event": "path.add",
"occurredAt": "2026-10-12T08:30:00.000Z",
"site": { "propertyId": "prop_123", "domain": "example.com" },
"path": {
"path": "/interviews/jane-street",
"type": { "key": "interviews", "name": "Interviews", "path": "/interviews" },
"part": "company",
"value": {
"value": "jane-street",
"name": "Jane Street",
"aliases": [],
"url": "https://<its own site>/",
"sameAs": ["https://<its careers page>/"],
"description": "<what it is, in a few words>",
"facts": [
{ "text": "<one sentence, as the source states it>", "source": "https://<the page that says it>", "lastVerified": "2026-10-01", "tier": "official" }
]
},
"takes": { "role": ["software-engineer"] }
}
}
| 필드 | 의미 |
|---|---|
path | 사이트에서 해당 값이 가지는 고유한 주소 |
type | { key, name, path } 형태의 콘텐츠 유형 |
part | 값이 채우는 주소의 부분 |
value | { value, name }이며, path.add에서는 AutoPSEO의 작성기가 수집한 aliases, url, sameAs, description, facts가 추가됩니다. 각 fact에는 text, source, lastVerified, 그리고 official 또는 reported 중 하나인 tier가 있습니다 |
takes | path.add에서: 이 값과 함께 검색되는 다른 부분의 값 |
redirectTo | path.remove에서: 이후 이 값의 주소가 301로 향해야 할 곳 |
명령을 처리했다면 2xx로 응답하세요. path.add에서는 값이 현재 위치한 url을, path.remove에서는 더 이상 제공하지 않는 urls를 추가할 수 있으며 이 주소들은 IndexNow로 전달됩니다. 거부하려면 { "error": "<code>", "details": ["<why>"] }와 함께 4xx로 응답하세요. path.add에서 invalid_value는 사용자의 이유와 함께 값을 작성기에 돌려보내며(세 번째 거부는 최종), 그 외의 코드는 값을 영구히 거부합니다. path.remove에서는 not_found가 완료로 처리되고, 409 in_use 같은 다른 코드는 모든 것을 그대로 둡니다. 대기 중인 경로 명령을 Done on the site로 표시하면 사이트의 승인으로 처리됩니다.
호출: AutoPSEO가 응답을 읽는 요청
호출은 전송과 같은 방식으로 서명됩니다. 호출은 기본적으로 최대 60초, 다시 읽기와 경로 확인은 30초까지 기다리며, 최대 24 MB의 응답을 읽습니다. 2xx가 아닌 응답은 모두 호출 실패로 처리됩니다.
site.export
요청에는 export: { what, cursor, limit }가 담기며, 주제에는 since(ISO 시각), 다시 읽기에는 paths가 추가됩니다. { "items": [], "next": "<cursor>" }로 응답하세요. AutoPSEO는 next가 비거나 null이 될 때까지 cursor를 사용자의 next로 설정해 다시 요청합니다.
what: "articles"는export에articles를 선언하고adopt: true일 때 전체 사이트 스캔에서 페이지당 10개씩 실행됩니다. 400 응답은 "지원하지 않음"을 의미합니다.paths: ["/answers/x"]와limit: 1이면 글 하나를 다시 읽으며, 이때는 그 글만 응답하세요.what: "topics"는 모든 스캔과 매일 하는 동기화에서 페이지당 50개씩 실행되며, 매일 하는 동기화는 마지막으로 가져온 시각인since를 보냅니다.what: "profile"은 모든 스캔에서 실행됩니다.{ "profile": { types, languages, rules, categories, format } }로 응답하며 각 항목은 선택 사항입니다.types는[{ key, name, path }]이고,rules에는brandTerms,synonyms,noise,onTopic,offTopic이 들어갑니다.
내보내는 글에는 externalId, slug(모든 문자 체계의 글자와 숫자, 하이픈, 최대 200자이며 세그먼트는 /로 연결), 해당 slug로 끝나는 path, 그리고 최소한 title, h1과 섹션 하나가 있는 copy가 필요합니다. 선택 필드는 lang(기본값 en), type(콘텐츠 유형 키), url, keyword, category, datePublished, dateModified, version, translations, owner(가져온 뒤의 AutoPSEO 글 id), 그리고 해당 작업을 큐에 넣는 maintenance: { kind: "refresh" or "optimize", reason }입니다. 내보낸 각 글은 사용자의 텍스트를 첫 리비전으로 하는 관리 대상 글이 되며, 사이트로 다시 전송되는 것은 없습니다. 다시 내보내도 안전하며, AutoPSEO가 삭제한 글은 내보내기를 통해 다시 돌아오지 않습니다.
주제 항목은 { keyword, status?, volume?, source?, externalId?, type?, reason? }이고, status는 queued, planned, rejected 중 하나입니다.
articles.adopted
가져온 뒤 AutoPSEO는 새 글을 관리해도 되는지 요청합니다: { "articles": [{ "externalId", "articleId", "slug", "path" }] }. { "adopted": ["<externalId>"], "refused": [{ "externalId": "<id>", "reason": "<why>" }] }로 응답하세요. 확인하지 않은 글은 AutoPSEO가 이미 그 글의 리비전을 작성한 경우가 아니라면 AutoPSEO에서 다시 제거됩니다.
site.handover
{ "handover": { "types": ["answers"], "writer": "autopseo" } }는 속성의 Your site 페이지에서 Let AutoPSEO write them 또는 Hand writing back to the site를 선택했을 때 전송됩니다. { "ok": true, "writer": "autopseo" }로 응답하세요. AutoPSEO에 넘긴 뒤에는 사이트가 직접 작성하는 대신, 검토한 주제를 AutoPSEO에 전달합니다.
path.check
{ "paths": [{ "path": "/interviews/google/data-engineer" }, { "path": "/interviews/jane-street", "new": { "part": "company", "value": "jane-street", "name": "Jane Street" } }] }. 경로마다 결과 하나씩 응답하세요: { "results": [{ "path": "/interviews/google/data-engineer", "ok": true }, { "path": "/interviews/jane-street", "ok": false, "error": "not_allowed", "details": ["We only list companies with a careers page."] }] }. 거부는 사용자의 이유와 함께 보관되며 다시 묻지 않습니다. 응답에서 빠뜨린 경로는 나중에 다시 묻습니다.
최소한의 수신기
Web Crypto API로 서명을 검증하고, 세 가지 글 명령을 처리하며, 나머지는 unknown_event로 거부하는 Cloudflare Worker입니다. 글은 ARTICLES로 바인딩된 KV 네임스페이스에 저장하고, 서명 시크릿은 AUTOPSEO_SECRET이라는 Worker 시크릿에 둡니다.
const HANDLED = ["article.published", "article.updated", "article.unpublished"];
const MAX_SKEW_SECONDS = 300;
const SITE = "https://example.com";
const json = (data, status = 200) =>
new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json" } });
function hexToBytes(hex) {
if (!/^[0-9a-f]{64}$/.test(hex)) return null;
const out = new Uint8Array(32);
for (let i = 0; i < 32; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
return out;
}
async function verified(request, body, secret) {
const timestamp = request.headers.get("X-AutoPSEO-Timestamp") || "";
const signature = (request.headers.get("X-AutoPSEO-Signature") || "").replace(/^sha256=/, "");
if (!/^\d+$/.test(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > MAX_SKEW_SECONDS) return false;
const bytes = hexToBytes(signature);
if (!bytes) return false;
const enc = new TextEncoder();
const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
// verify() compares in constant time.
return crypto.subtle.verify("HMAC", key, bytes, enc.encode(`${timestamp}.${body}`));
}
export default {
async fetch(request, env) {
if (request.method !== "POST") return new Response("Method not allowed", { status: 405 });
const body = await request.text();
if (!(await verified(request, body, env.AUTOPSEO_SECRET))) return json({ error: "bad_signature" }, 401);
let msg;
try {
msg = JSON.parse(body);
} catch {
return json({ error: "bad_json" }, 400);
}
if (msg.event === "ping") return json({ ok: true, autopseo: { version: 1, events: HANDLED } });
if (msg.event === "test") {
const command = msg.test && msg.test.event;
// Check the sample as you would the real command, and store nothing.
return HANDLED.includes(command)
? json({ test: command, ok: true })
: json({ test: command, ok: false, error: "unknown_event" });
}
if (msg.event === "article.published" || msg.event === "article.updated") {
const a = msg.article;
const stored = JSON.parse((await env.ARTICLES.get(a.id)) || "null");
if (a.basedOn && stored && stored.version && stored.version !== a.basedOn) {
return json({ error: "conflict", details: ["The page changed on the site."] }, 409);
}
// Any change to the page, here or in your own editor, gets a new version.
const version = crypto.randomUUID();
await env.ARTICLES.put(a.id, JSON.stringify({ ...a, version }));
return json({ url: SITE + a.path, version });
}
if (msg.event === "article.unpublished") {
const a = msg.article;
if (a.redirectTo) await env.ARTICLES.put(a.id, JSON.stringify({ path: a.path, redirectTo: a.redirectTo }));
else await env.ARTICLES.delete(a.id);
return json({ ok: true });
}
return json({ error: "unknown_event" }, 400);
},
};
Node.js에서는 프레임워크가 받은 원본 본문에 대해 createHmac과 timingSafeEqual로 같은 방식으로 검증합니다.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyAutopseo(rawBody, headers, secret) {
const timestamp = String(headers["x-autopseo-timestamp"] || "");
const given = String(headers["x-autopseo-signature"] || "");
if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = "sha256=" + createHmac("sha256", secret).update(timestamp + ".").update(rawBody).digest("hex");
return given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}
다음으로: 웹훅에 무엇이 전달되는지는 작성 및 검토와 유지 관리에서 결정되며, 전체 흐름은 콘텐츠 엔진 개요에서 확인할 수 있습니다.