Skip to main content
Almost every failed integration fails in one of the ways below. Find your symptom, not your theory.
Castro shows you the real HTTP status and your own error message for every request, in Settings → Integration → Custom Website → Logs. Start there. The answer is usually in your own response body.

Connecting

Castro couldn’t reach your /handshake, or didn’t like the answer. In order of likelihood:Your base URL isn’t publicly reachable. Castro’s servers call you. http://localhost:3000 works only on your own machine. Deploy it, or use a tunnel (ngrok http 3000) while developing.The path is wrong. You give Castro a base URL, not a full endpoint. If your handshake lives at https://site.com/api/castro/handshake, the base URL is https://site.com/api/castro. Castro appends /handshake itself.Your server rejected Castro’s own signature. The handshake request is signed like every other request. If your signature check is broken, the handshake never even runs. Confirm with:
Your server answered, but the challenge_response was wrong.The classic mistake: signing the challenge with the key that arrived in the request header instead of your own stored key.
Note it’s HMAC(challenge): the challenge string on its own. No timestamp, no dot, no body. That formula is only for request signatures.
Your handshake’s capabilities array must contain both posts.create and posts.update. An integration that can’t publish or update a post has nothing to offer Castro, so the connection is refused rather than left in a half-working state.

Authentication

You’re almost certainly verifying the signature over re-serialized JSON instead of the raw bytes.The signature covers exactly what came down the wire. The moment you JSON.parse() and re-stringify(), key order and whitespace can shift, and the HMAC no longer matches. The nasty part: it matches for some payloads, so it looks intermittent.
X-Castro-Timestamp is unix milliseconds. Castro signs with Date.now(). If you compare it against a seconds-based clock, every request looks about 55,000 years in the future.
In PHP, time() is seconds: use round(microtime(true) * 1000). In Python, time.time() is float seconds: use time.time() * 1000.
Requests without a body sign over "{timestamp}.": the timestamp, a dot, and an empty string. If your code skips the dot when the body is empty, or signs over the literal string "null" or "undefined", the HMAC won’t match.
The connection key was regenerated in Castro. Rotating the key immediately invalidates the old one and marks the site disconnected.Update the key on your server, then click Connect website again in Castro.

Publishing

This is the single most expensive bug in this contract, and it is always the same cause: treating PUT /posts/{id} as a full replace.The update is partial. Apply only the fields present in the body; leave everything else exactly as it was. Castro’s Update Content action sends only { content, source_id }. If you overwrite the whole record with that, the title and every other field become empty.
Nested seo deserves the same care: an SEO push carrying only a description must not wipe the title. Merge the object, don’t replace it.The conformance script asserts this. If it passes, you’re safe.
Use source_id. Every payload for the same piece of Castro content carries the same source_id, so you can recognise a re-publish:
Store it on create, and index it.
They are names, strings, on purpose. Castro has no idea what your category ids look like. Create any category that doesn’t exist yet, then attach it.The same is true of tags and author.
content is HTML with the H1 already removed. Render title as the page heading yourself. If you’re also injecting the title into the body, you’ll get it twice.
You didn’t declare the capability that action needs. Castro never calls an endpoint you didn’t declare, even if you built it.Add it to your /handshake response, then click Re-verify connection in Castro (Settings → Integration → Custom Website → Manage). No key rotation needed. See capabilities.

Page sync

Page sync needs the pages.list capability. Implement GET /pages, add "pages.list" to your handshake’s capability array, and click Re-verify connection. The button appears once Castro sees the capability.
Sync matches the URLs from your GET /pages against the URLs Castro crawled. They have to be the same URLs.
  • Return absolute URLs (https://site.com/blog/hello), not paths.
  • Use the canonical form: pick one of https:// vs http://, www vs bare, trailing slash vs none, and be consistent with what your site actually serves.
  • Make sure Castro has crawled your site at all. Sync stamps identity onto crawled pages; with no crawl, there’s nothing to stamp. Run a crawl first.
Pages Castro has never crawled aren’t lost: they’re queued for crawling, and the next sync matches them.
GET /pages lists published pages only. Castro treats everything you return as publicly reachable: it crawls those URLs and, if one wasn’t in the crawl, queues it. Filter drafts out of that list.

Still stuck?

Run the conformance script and send the output to support. It tells us in one paste which side of the contract is broken.