Building a Graph API Page App with Three Required Endpoints
Most people fail review not because their main code is bad, but because they forget three endpoints that do nothing in their own system, yet the platform treats them as promises made to users.
I had a job where the backend needed to read incoming Page messages, reply automatically in certain cases, and publish announcements on a schedule. There is only one proper way to handle this kind of requirement: register an app with the platform, request the permissions it needs, and get it through review first.
I finished the main code in a day, but failed the first review over something completely unrelated to it: three required endpoints I had not built, because when I first read the documentation, I thought they were optional extras for apps that collected substantial user data, which was completely wrong.
I made up all the app IDs, secrets, tokens, Page names, and domains in this article. The code structure, field names, and sequence of operations are from the actual job.
Two routes to choose between before writing the first line of code
Before getting started, I recommend making this decision first, because it changes the entire job.
Use a provider that has already passed review
- Start using it within a day, with no review to wait for
- No required endpoints to maintain yourself
- Customer messages pass through a third party
- Monthly fees and limits on what you can do
Build your own app and submit it for review
- All data stays in your system, with any integration you need
- No monthly service fees
- Requires a publicly accessible domain and all required endpoints
- Review takes days to weeks, and you can fail
I chose the second route because this job needed to integrate with an existing backend, and I did not want customer messages passing through another system. But for simple automatic replies, the first route is much better value; there is no need to force it.
Everything in this article uses the platform's official interface, with tokens obtained when the Page owner explicitly grants permission. Other methods that try to access data outside this channel violate the terms of service and risk getting the customer's account suspended, not just the app. When working for a customer, I must not expose them to that risk.
Three endpoints that will cost you a review if you forget them
When an app requests access to user data, the platform treats that as three promises to the user, and those promises must be backed by real endpoints on the app's domain.
The first must accept a signed POST request and return JSON with two keys: a status page URL and a confirmation code. The second is a page that must display the status when opened with that confirmation code. The third must accept a request when the user revokes access and return 200. If any one returns the wrong format, the entire review fails, and the feedback usually says only that it failed, without identifying which endpoint.
I wrote all three as plain PHP files, each under thirty lines, in less than an hour altogether. Compared with waiting several more days for another review, that was a very small cost.
Verify incoming request signatures; do not trust the payload alone
The first two requests come with a parameter named signed_request, which contains two parts separated by a period. The first part is the signature, and the second is the payload. Both parts use URL-safe base64 encoding, which substitutes a hyphen and an underscore for two characters in standard base64.
If I decode the second part and trust it without checking the signature, anyone can forge a request to delete someone else's data.
<?php
// ตรวจและถอด signed_request — คืน null ถ้าลายเซ็นไม่ตรง
function fb_parse_signed_request($signed_request, $app_secret) {
if (!$signed_request || strpos($signed_request, '.') === false) return null;
list($encoded_sig, $payload) = explode('.', $signed_request, 2);
// base64url -> base64 ปกติ ก่อนถอด
$sig = base64_decode(strtr($encoded_sig, '-_', '+/'));
$data = json_decode(base64_decode(strtr($payload, '-_', '+/')), true);
if (!is_array($data)) return null;
// เซ็นจาก "ท่อนหลังดิบ" ไม่ใช่จากข้อมูลที่ถอดแล้ว
$expected = hash_hmac('sha256', $payload, $app_secret, true);
// เทียบแบบเวลาคงที่ ป้องกันการเดาทีละไบต์จากเวลาที่ใช้ตอบ
if (!hash_equals($expected, $sig)) return null;
return $data;
}
The first is recalculating the signature from the decoded data, which will never match because the signature is computed from the raw second part before decoding. The second is comparing signatures with the ordinary triple-equals operator, which stops at the first differing byte, allowing the response time to hint at how many bytes were guessed correctly. Always use a constant-time comparison function.
The data deletion endpoint must return two keys
This is the endpoint that causes the most review failures, because it must return JSON with a fixed structure, rather than a web page.
<?php
// data-deletion.php — แพลตฟอร์มยิง POST มาเมื่อผู้ใช้กดขอลบข้อมูล
$cfg = require __DIR__ . '/fb_config.php';
require __DIR__ . '/_fbhelp.php';
$data = fb_parse_signed_request($_POST['signed_request'] ?? '', $cfg['app_secret']);
if (!$data || empty($data['user_id'])) {
http_response_code(400);
header('Content-Type: application/json');
echo json_encode(['error' => 'invalid signed_request']);
exit;
}
$uid = preg_replace('/\D/', '', $data['user_id']); // เหลือแต่ตัวเลข
$code = bin2hex(random_bytes(8)); // รหัสยืนยัน 16 ตัวอักษร
@mkdir(__DIR__ . '/_deletions', 0755, true);
@file_put_contents(__DIR__ . '/_deletions/' . $code . '.json', json_encode([
'user_id' => $uid,
'requested_at' => date('c'),
'status' => 'received',
]));
// ต่อจากนี้คือหน้าที่ของระบบหลังบ้าน: ลบข้อมูลของ $uid ออกจากฐานข้อมูลจริง
header('Content-Type: application/json');
echo json_encode([
'url' => 'https://example.com/deletion-status.php?code=' . $code,
'confirmation_code' => $code,
]);
The response must look exactly like this. Get even one key name wrong and it fails.
{
"url": "https://example.com/deletion-status.php?code=9f3c1a7b2e5d4c86",
"confirmation_code": "9f3c1a7b2e5d4c86"
}
The status page at that link does not have to look good, but it must actually open with the supplied code and show the status in human-readable language. I always filter the code with a regular expression to leave only hexadecimal characters before using it to construct a filename, because that value comes from the URL.
<?php
// deletion-status.php — หน้าที่ผู้ใช้เปิดจากลิงก์ที่เราส่งกลับไป
header('Content-Type: text/html; charset=utf-8');
// กรองให้เหลือแต่ฐานสิบหก กันการไต่โฟลเดอร์ผ่านชื่อไฟล์
$code = preg_replace('/[^a-f0-9]/', '', $_GET['code'] ?? '');
$f = __DIR__ . '/_deletions/' . $code . '.json';
echo '<meta charset=utf-8><title>สถานะคำขอลบข้อมูล</title>';
if ($code !== '' && is_file($f)) {
echo '<h2>คำขอลบข้อมูล</h2>'
. '<p>รหัสยืนยัน: <b>' . htmlspecialchars($code) . '</b></p>'
. '<p>สถานะ: ได้รับคำขอแล้ว ระบบจะลบข้อมูลที่เกี่ยวข้องกับบัญชีของคุณ</p>'
. '<p>สอบถามเพิ่มเติม [email protected]</p>';
} else {
echo '<h2>ไม่พบคำขอ</h2><p>รหัสไม่ถูกต้องหรือหมดอายุ · [email protected]</p>';
}
The third endpoint is the shortest: it receives a request when a user revokes the app's permissions. My job is to record it, remove that user's tokens from the system, and return 200.
<?php
// deauthorize.php — ผู้ใช้ถอดแอปออกจากบัญชีของตัวเอง
$cfg = require __DIR__ . '/fb_config.php';
require __DIR__ . '/_fbhelp.php';
$data = fb_parse_signed_request($_POST['signed_request'] ?? '', $cfg['app_secret']);
if ($data && !empty($data['user_id'])) {
@file_put_contents(__DIR__ . '/_deauth.log',
date('c') . ' user_id=' . preg_replace('/\D/', '', $data['user_id']) . "\n",
FILE_APPEND);
// ต่อจากนี้: ลบโทเคนและสิทธิ์ของผู้ใช้คนนี้ออกจากระบบจริง
}
http_response_code(200);
echo 'ok';
Before submitting for review, I tested all three with a single-line command to make sure the status codes were as expected.
# ยิงคำขอลบข้อมูลแบบไม่มีลายเซ็น ต้องได้ 400 ไม่ใช่ 500 และไม่ใช่ 200
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://example.com/data-deletion.php
# หน้าสถานะด้วยรหัสมั่ว ต้องได้ 200 พร้อมข้อความว่าไม่พบคำขอ ไม่ใช่หน้าขาว
curl -s https://example.com/deletion-status.php?code=zzzz | head -c 200
# ถอนสิทธิ์แบบไม่มีลายเซ็น ต้องได้ 200 เปล่าๆ ไม่ใช่ 500
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://example.com/deauthorize.php
Exchange a short-lived token for a Page token once and be done
This is the part that confuses people most, because there are several token types with different lifetimes. The correct sequence has three steps.
- The Page owner authorizes the app, and I receive a short-lived user token. It lasts about two hours and is unsuitable for production use.
- I exchange the short-lived token for a long-lived user token at oauth/access_token, sending the app ID and app secret along with it. This lasts about sixty days.
- I use the long-lived user token to call me/accounts, which returns the Pages that user administers, along with a Page token for each one. A Page token issued from a long-lived user token never expires.
If I skip step two and call me/accounts directly with the short-lived token, the resulting Page token expires along with it, meaning the system will break in two hours without me realizing it. This is the number one cause of “it was still working yesterday.”
<?php
// 1) โทเคนผู้ใช้แบบสั้น -> แบบยาว
$ll = fb_api('GET', 'oauth/access_token', [
'grant_type' => 'fb_exchange_token',
'client_id' => $cfg['app_id'],
'client_secret' => $cfg['app_secret'],
'fb_exchange_token' => $short_user_token,
]);
if (empty($ll['access_token'])) {
exit('แลกโทเคนยาวไม่สำเร็จ: ' . json_encode($ll, JSON_UNESCAPED_UNICODE));
}
// 2) โทเคนผู้ใช้แบบยาว -> รายการเพจ พร้อมโทเคนเพจ
$accts = fb_api('GET', 'me/accounts', ['fields' => 'id,name,access_token'], $ll['access_token']);
if (empty($accts['data'])) {
exit('ไม่พบเพจที่เป็นผู้ดูแล หรือขอบเขตสิทธิ์ไม่พอ');
}
$page = $accts['data'][0]; // ถ้ามีหลายเพจ ให้เลือกด้วยรหัสเพจแทนการหยิบตัวแรก
// 3) สมัครรับเหตุการณ์ของเพจเข้าแอป
fb_api('POST', $page['id'] . '/subscribed_apps',
['subscribed_fields' => 'messages,messaging_postbacks,feed'],
$page['access_token']);
I put the Graph API calls in one function shared across the project. The important point is that it always returns an array and does not throw errors, because the caller must always be able to read the error key.
<?php
function fb_api($method, $path, $params = [], $token = null) {
$ver = 'v25.0'; // ตรึงรุ่นไว้ อย่าใช้เส้นทางที่ไม่ระบุรุ่น
$url = 'https://graph.facebook.com/' . $ver . '/' . ltrim($path, '/');
if ($token) $params['access_token'] = $token;
$ch = curl_init();
if (strtoupper($method) === 'GET') {
curl_setopt($ch, CURLOPT_URL, $url . '?' . http_build_query($params));
} else {
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($params));
}
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
$res = curl_exec($ch);
$err = curl_error($ch);
curl_close($ch);
if ($res === false) return ['error' => ['message' => $err ?: 'curl failed']];
$j = json_decode($res, true);
return is_array($j) ? $j : ['error' => ['message' => 'bad json', 'raw' => $res]];
}
Pinning the API version is something I insist on, because the platform retires old versions periodically. Without an explicit version, the system will change behavior on the day the platform changes its default, even though I have changed nothing.
Page tokens must stay somewhere the web server cannot serve
A Page token that never expires is the most valuable item in the entire system. Anyone who gets it can post as the customer's Page and read every message in its inbox.
Do not store tokens in a directory served by the web server, no matter how hard the filename is to guess. The day the web server mishandles a file extension, or someone can list the directory contents, everything leaks at once. The proper approach is to store them in a directory one level above the web root and set file permissions so that only the owner can read and write them. If shared hosting truly prevents moving the files outside it, block access to the directory through the web server's configuration file, while accepting that this provides weaker protection.
<?php
// fb_config.php — ค่าลับทั้งหมดอ่านจากสภาพแวดล้อม ไม่มีค่าจริงอยู่ในซอร์สที่ commit
return [
'app_id' => getenv('FB_APP_ID'),
'app_secret' => getenv('FB_APP_SECRET'),
'verify_token' => getenv('FB_VERIFY_TOKEN'), // ใช้ตอนจับมือกับ webhook
'api_secret' => getenv('FB_INTERNAL_BEARER') // กันปลายทางภายในของเราเอง
];
// ที่เก็บโทเคนเพจ: อยู่เหนือรากเว็บขึ้นไปหนึ่งชั้น
function fb_token_file() { return dirname(__DIR__) . '/private/page_token.json'; }
When I first write the token file, I set its permissions to 0600 immediately on the next line, without waiting for someone to do it later. The time between file creation and the permission change is a window in which neighbors on shared hosting can read it.
Incoming events need another signature check
The event endpoint handles two modes in the same file. GET is the handshake during initial setup: I must compare the configured value and return the raw challenge value. POST carries actual events, with a signature in the headers.
<?php
// webhook.php
$cfg = require __DIR__ . '/fb_config.php';
require __DIR__ . '/_fbhelp.php';
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'GET') {
$mode = $_GET['hub_mode'] ?? '';
$tok = $_GET['hub_verify_token'] ?? '';
if ($mode === 'subscribe' && hash_equals($cfg['verify_token'], $tok)) {
header('Content-Type: text/plain');
echo $_GET['hub_challenge'] ?? ''; // ต้องส่งค่าท้าทายกลับดิบๆ ถึงจะผ่าน
exit;
}
http_response_code(403);
exit('verify failed');
}
// POST: เหตุการณ์จริง — ต้องเซ็นจาก "เนื้อความดิบ" ก่อน json_decode
$raw = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? '';
$ok = false;
if (strpos($sig, 'sha256=') === 0) {
$expected = 'sha256=' . hash_hmac('sha256', $raw, $cfg['app_secret']);
$ok = hash_equals($expected, $sig);
}
if (!$ok) { http_response_code(403); exit('bad signature'); }
@file_put_contents(__DIR__ . '/_events.log',
json_encode(['at' => date('c'), 'body' => json_decode($raw, true)], JSON_UNESCAPED_UNICODE) . "\n",
FILE_APPEND | LOCK_EX);
http_response_code(200);
echo 'EVENT_RECEIVED';
During early testing, I logged events to a file without rejecting requests that failed signature verification, so I could see what was coming in. But in production, this must always change to immediate rejection; otherwise, anyone who guesses the endpoint URL can inject fake events into the system.
Conclusion
The hard part of this job was not calling the API, because the documentation was clear and the examples were complete. The hard part was three things unrelated to the app's features: the three required endpoints, correct signature verification, and secure token storage.
I boil it down to three short points. First, implement all required endpoints before submitting for review. Writing three files takes less than an hour, but one failed review costs a week. Second, always exchange for a long-lived user token first, then request the Page token; otherwise, the system dies in two hours. Third, keep Page tokens outside the web root, without exception, because they are the keys to the customer's Page, not mine.
Anyone who prefers a provider that has already passed review over building their own app is welcome to take that route. If automatic replies are all that is needed, it really is better value.
P.S. What I have not covered this time is handling a Page token that stops working during operation, such as when the Page owner removes my admin access. Currently, the system only logs the error, which means it could go silent for hours before anyone notices. It should check the error code and send an alert immediately. I will come back and add that later.
Want us to set this up?
Get help with server setup, configuration, migration, or troubleshooting. Save time for the work that matters.
Hit something like this? Stuck somewhere?
Come trade notes on the LinuxThai board



Comments 0
No comments yet — be the first